diff --git a/.gitattributes b/.gitattributes index 1b8a169a6c..794e109aae 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,5 @@ src/UniGetUI.PackageEngine.Managers.Chocolatey/choco-cli/** linguist-vendored -.githooks/* text eol=lf \ No newline at end of file +.githooks/* text eol=lf + +policies/samples/** linguist-generated +policies/schemas/** linguist-generated \ No newline at end of file diff --git a/.gitignore b/.gitignore index 711cadf3d3..4c20afaa00 100644 --- a/.gitignore +++ b/.gitignore @@ -96,3 +96,7 @@ src/UniGetUI.v3.ncrunchsolution # macOS Finder metadata .DS_Store /src/UniGetUI.Avalonia/Generated Files + + +policies/csharp/.vs/* +src/UniGetUI.PackageEngine.Managers.WinGet \ No newline at end of file diff --git a/global.json b/global.json index c358071f08..abe5820849 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { "version": "10.0.103", - "rollForward": "latestPatch" + "rollForward": "latestFeature" } } diff --git a/policies/README.md b/policies/README.md new file mode 100644 index 0000000000..f91300b19e --- /dev/null +++ b/policies/README.md @@ -0,0 +1,327 @@ +# UniGetUI Package Broker Policies + +This document describes a proposed package policy format for UniGetUI. The goal is to let IT admins publish allow-list or deny-list policy files that an elevated UniGetUI broker service can evaluate before running package manager operations requested by the regular unelevated UniGetUI process. + +The initial format covers WinGet and PowerShell Gallery requests. It is intentionally shaped around UniGetUI package operation data: package identity, manager, source, operation, scope, architecture, elevation, integrity options, prerelease, install location, and custom parameters. + +## Files + +| File | Purpose | +| --- | --- | +| `schemas/unigetui.package-policy.schema.1.0.json` | JSON Schema for admin-authored policy files | +| `schemas/unigetui.package-request.schema.1.0.json` | JSON Schema for canonical unelevated-to-broker package requests | +| `schemas/unigetui.package-broker-response.schema.1.0.json` | JSON Schema for canonical broker decision responses | +| `named-pipe-http-wire-protocol.md` | Proposed HTTP-over-named-pipe transport and wire format | +| `samples/corporate-allowlist.policy.json` | Fail-closed WinGet allow-list sample | +| `samples/corporate-allowlist.policy.yaml` | YAML form of a fail-closed WinGet allow-list sample | +| `samples/deny-risky-options.policy.json` | Default-allow policy that denies risky request options | +| `samples/powershell-current-user.policy.json` | PowerShell Gallery CurrentUser-only sample | +| `samples/powershell-advanced.policy.json` | PowerShell source, version-range, and update-operation coverage sample | +| `samples/scenario-coverage.policy.json` | Focused policy for precedence, version, and constraint scenarios | +| `samples/requests/winget-vscode-install.request.yaml` | YAML form of a canonical WinGet request sample | +| `samples/responses/*.response.json` | Canonical broker response samples | +| `samples/wire/*.http` | Raw HTTP-over-named-pipe request and response examples | +| `samples/scenarios/*.scenarios.json` | Data-driven scenario manifests with expected decisions and rule ids | +| `samples/invalid/` | Invalid policy and request fixtures for fail-closed validation scenarios | +| `scripts/Invoke-UniGetUIPolicySimulation.ps1` | Runs one policy against one or more request files | +| `scripts/Test-UniGetUIPolicySamples.ps1` | Runs the bundled end-to-end sample cases | +| `csharp/UniGetUI.PolicySimulator.slnx` | C# end-to-end policy simulator solution with shared engine, server, client, and tests | + +## Trust Boundary + +The unelevated UniGetUI process should not directly choose an elevated command line. Instead, it should send a canonical package operation request to the elevated broker. The broker validates the request shape, validates the policy, evaluates policy rules, and only then constructs or runs the elevated package manager operation. + +The policy decision must be made over the request data, not only the package id. A package id that is approved for silent WinGet install from the `winget` source might still be denied if the request asks for `--ignore-security-hash`, a custom `--override`, a different source, a prerelease package, or a pre/post operation command. + +Recommended production behavior is fail closed: + +1. If the policy cannot be loaded, deny. +2. If the request cannot be validated, deny. +3. If no rule matches and `defaultDecision` is `deny`, deny. +4. If two matching rules share a priority, deny wins. + +## Broker Flow + +1. The unelevated UniGetUI process resolves the selected package and options into a canonical request document. +2. The elevated broker validates the request against `unigetui.package-request.schema.1.0.json`. +3. The broker loads the admin policy and validates it against `unigetui.package-policy.schema.1.0.json`. +4. Disabled rules are ignored. +5. Every enabled rule is matched against the canonical request. +6. Matching rules are sorted by `priority`, where the lowest number wins. +7. If matching rules share the same priority, a `deny` rule wins over an `allow` rule. +8. If no rule matches, `enforcement.defaultDecision` is used. +9. If the final decision is `allow`, the broker builds the package manager command from trusted request fields and the selected UniGetUI manager helper semantics. +10. If the final decision is `deny`, the broker returns the policy reason to the client and does not run the package manager. + +The proposed production transport is HTTP/1.1 over a Windows named pipe, documented in `named-pipe-http-wire-protocol.md`. The request body remains the canonical package request schema, and the response body uses `unigetui.package-broker-response.schema.1.0.json`. + +## Policy Format + +A policy file can be authored as JSON or YAML, following WinGet's practical authoring model. The JSON Schema files remain the authoritative contract; YAML documents are parsed and normalized to canonical JSON before schema validation and rule evaluation. + +Supported file extensions are `.json`, `.yaml`, and `.yml`. The simulation scripts use `ConvertFrom-Yaml` from the `powershell-yaml` module when available. When running under Windows PowerShell without that command, they can fall back to an installed `pwsh` that can import `powershell-yaml`. + +JSON policies start with version and type fields inspired by WinGet manifest conventions: + +```json +{ + "$schema": "https://aka.ms/unigetui/package-policy.schema.1.0.json", + "policyVersion": "1.0.0", + "policyType": "packageBrokerPolicy", + "metadata": { + "id": "contoso.desktop.standard-allowlist", + "publisher": "Contoso IT", + "revision": 4, + "publishedAt": "2026-05-05T00:00:00Z" + }, + "enforcement": { + "defaultDecision": "deny", + "failureDecision": "deny", + "rulePrecedence": "priorityThenDeny" + }, + "rules": [] +} +``` + +The same policy shape can be written as YAML: + +```yaml +"$schema": https://aka.ms/unigetui/package-policy.schema.1.0.json +policyVersion: 1.0.0 +policyType: packageBrokerPolicy +metadata: + id: contoso.desktop.standard-allowlist-yaml + publisher: Contoso IT + revision: 1 + publishedAt: "2026-05-05T00:00:00Z" +enforcement: + defaultDecision: deny + failureDecision: deny + rulePrecedence: priorityThenDeny +rules: [] +``` + +Rules contain four core fields: + +| Field | Description | +| --- | --- | +| `id` | Stable rule id for audit logs | +| `priority` | Lower number wins | +| `decision` | `allow` or `deny` | +| `match` | Selectors that must all match the request | + +Optional `constraints` let an allow rule reject risky variants of an otherwise approved package. For example, the allow-list sample permits `Microsoft.VisualStudioCode` but rejects custom parameters, integrity bypasses, prerelease packages, pre/post commands, and kill-before-operation process lists. + +```json +{ + "id": "allow.winget.vscode", + "priority": 100, + "decision": "allow", + "reason": "Visual Studio Code is approved for managed workstations.", + "match": { + "operations": [ "install", "update" ], + "managers": [ "Winget" ], + "sources": [ "winget" ], + "packageIdentifiers": [ "Microsoft.VisualStudioCode" ], + "scopes": [ "user", "machine" ], + "architectures": [ "x64", "arm64" ] + }, + "constraints": { + "allowInteractive": false, + "allowSkipHashCheck": false, + "allowPreRelease": false, + "allowCustomParameters": false, + "allowPrePostCommands": false, + "allowKillBeforeOperation": false + } +} +``` + +Selectors are case-insensitive for wildcard string fields. The `packageIdentifiers`, `packageNames`, and `sources` selectors accept exact values or `*` wildcards, such as `Microsoft.*` or `PS*`. + +## Request Format + +A request file models what the unelevated executable asks the elevated broker to do. It mirrors UniGetUI's package operation inputs rather than a raw shell command. + +```json +{ + "$schema": "https://aka.ms/unigetui/package-request.schema.1.0.json", + "requestVersion": "1.0.0", + "requestType": "packageOperation", + "requestId": "req-winget-vscode-install", + "createdAt": "2026-05-05T12:00:00Z", + "operation": "install", + "manager": { + "name": "Winget", + "displayName": "WinGet", + "executableFriendlyName": "winget.exe" + }, + "source": { + "name": "winget", + "url": "https://cdn.winget.microsoft.com/cache" + }, + "package": { + "id": "Microsoft.VisualStudioCode", + "name": "Microsoft Visual Studio Code" + }, + "options": { + "scope": "machine", + "architecture": "x64", + "interactive": false, + "skipHashCheck": false, + "preRelease": false, + "customParameters": [] + }, + "broker": { + "requestedElevation": "elevated", + "effectiveUser": "CONTOSO\\alice", + "clientVersion": "3.2.0" + } +} +``` + +The request schema intentionally does not include a client-supplied command field. Runtime brokers and simulators should build the final command from validated request fields and UniGetUI manager helpers instead of trusting command text from the client. + +## Manager Mapping + +### WinGet + +WinGet requests map to the current UniGetUI WinGet operation helper semantics: + +| Request field | WinGet command meaning | +| --- | --- | +| `operation` | `install`, `upgrade` for request `update`, or `uninstall` | +| `package.id` | `--id --exact` | +| `source.name` | `--source ` | +| `options.scope` | `--scope user` or `--scope machine` | +| `options.version` | `--version ` when supplied | +| `options.interactive` | `--interactive` when true, otherwise `--silent` | +| `options.architecture` | `--architecture x86`, `x64`, or `arm64` | +| `options.skipHashCheck` | `--ignore-security-hash` when true | +| `options.customInstallLocation` | `--location ` | +| `options.customParameters` | Additional operation parameters, if policy allows them | + +Known WinGet sources in UniGetUI include `winget`, `winget-fonts`, and `msstore`. The sample policies treat source as part of the trusted identity. + +### PowerShell Gallery + +PowerShell requests map to the current UniGetUI PowerShell operation helper semantics: + +| Request field | PowerShell command meaning | +| --- | --- | +| `operation` | `Install-Module`, `Update-Module`, or `Uninstall-Module` | +| `package.id` | `-Name ` | +| `options.scope` | `-Scope CurrentUser` or `-Scope AllUsers` for install operations | +| `options.version` | `-RequiredVersion ` when supplied | +| `options.preRelease` | `-AllowPrerelease` when true | +| `options.skipHashCheck` | `-SkipPublisherCheck` when true | +| `options.customParameters` | Additional operation parameters, if policy allows them | + +Known PowerShell sources in UniGetUI include `PSGallery` and `PoshTestGallery`. The `powershell-current-user.policy.json` sample uses source, package id, scope, and elevation as decision inputs. + +## Scenario Outcomes + +Bundled scenarios are data-driven through manifest files under `samples/scenarios/`. Each scenario declares an id, policy fixture, request fixture, expected decision, expected rule id, and tags. The current manifests cover these categories: + +| Category | Coverage | +| --- | --- | +| Baseline allow-list | Approved WinGet and PowerShell requests, unknown-package default deny, and PowerShell machine-scope deny | +| Baseline deny-list | Default-allow behavior with explicit denials for hash/publisher bypass, custom parameters, and unapproved WinGet sources | +| JSON/YAML parity | JSON policy with YAML request, YAML policy with JSON request, and YAML policy with YAML request | +| Rule precedence | Disabled rules are ignored and deny wins when allow and deny rules match at the same priority | +| Operation mapping | Install, update, and uninstall decisions, including WinGet update-to-upgrade command construction | +| Source and architecture matching | Unapproved WinGet source default deny, WinGet architecture mismatch default deny, and untrusted PowerShell source deny | +| Version matching | Allowed WinGet and PowerShell version ranges, out-of-range default deny, and prerelease version default deny | +| Constraints | Allowed and denied custom install locations and package-manager parameters | +| Risky options | Denials for interactive installs, pre/post commands, kill-before-operation, prerelease modules, and publisher-check bypass | +| Validation | Invalid request and policy fixtures fail closed with a deny decision | + +## Running The Simulation + +Run all bundled sample cases: + +```powershell +pwsh -NoProfile -ExecutionPolicy Bypass -File .\policies\scripts\Test-UniGetUIPolicySamples.ps1 +``` + +List scenarios without running them: + +```powershell +pwsh -NoProfile -ExecutionPolicy Bypass -File .\policies\scripts\Test-UniGetUIPolicySamples.ps1 -List +``` + +Run a tagged subset: + +```powershell +pwsh -NoProfile -ExecutionPolicy Bypass -File .\policies\scripts\Test-UniGetUIPolicySamples.ps1 -Tag winget +``` + +Run a specific scenario manifest: + +```powershell +pwsh -NoProfile -ExecutionPolicy Bypass -File .\policies\scripts\Test-UniGetUIPolicySamples.ps1 ` + -ScenarioPath .\policies\samples\scenarios\extended.scenarios.json +``` + +Run a single policy against one or more requests: + +```powershell +pwsh -NoProfile -ExecutionPolicy Bypass -File .\policies\scripts\Invoke-UniGetUIPolicySimulation.ps1 ` + -PolicyPath .\policies\samples\corporate-allowlist.policy.json ` + -RequestPath .\policies\samples\requests\winget-vscode-*.request.json +``` + +Run a YAML policy against a YAML request: + +```powershell +pwsh -NoProfile -ExecutionPolicy Bypass -File .\policies\scripts\Invoke-UniGetUIPolicySimulation.ps1 ` + -PolicyPath .\policies\samples\corporate-allowlist.policy.yaml ` + -RequestPath .\policies\samples\requests\winget-vscode-install.request.yaml +``` + +The simulator validates JSON or YAML syntax, normalizes YAML to JSON, optionally uses `Test-Json` for JSON Schema validation when available, performs semantic validation, evaluates rules, and prints the selected decision, rule id, and reason. The sample test runner loads scenario manifests and also asserts expected rule ids when a scenario declares one. It never runs a real package manager. + +## Running The C# Policy Server Simulator + +The C# policy server simulator models the elevated broker boundary. It listens only on loopback by default, receives canonical request documents from a client process, validates JSON or YAML request bodies against the request schema, evaluates them against one configured policy, and returns either a denial or the command an elevated broker would run. It does not execute package managers. + +Build the C# sample: + +```powershell +dotnet build .\policies\csharp\UniGetUI.PolicySimulator.slnx +``` + +Run the C# scenario and command-construction test runner: + +```powershell +dotnet run --project .\policies\csharp\UniGetUI.PolicySimulator.Tests\UniGetUI.PolicySimulator.Tests.csproj -- --policy-root .\policies +``` + +Start the server with a policy: + +```powershell +dotnet run --project .\policies\csharp\UniGetUI.PolicySimulator.Server\UniGetUI.PolicySimulator.Server.csproj -- ` + --policy .\policies\samples\corporate-allowlist.policy.json ` + --url http://127.0.0.1:8765 +``` + +Check readiness: + +```powershell +Invoke-RestMethod http://127.0.0.1:8765/health +``` + +Send a request as the unelevated client would: + +```powershell +dotnet run --project .\policies\csharp\UniGetUI.PolicySimulator.Client\UniGetUI.PolicySimulator.Client.csproj -- ` + --server http://127.0.0.1:8765 ` + --request .\policies\samples\requests\winget-vscode-install.request.json ` + --json +``` + +Allowed responses include `wouldExecute: true` and an `execution.command` array. Denied responses return `wouldExecute: false`, the selected policy rule id, and the denial reason. Invalid policy or request documents fail closed with `decision: deny` and `ruleId: `. The server accepts JSON and YAML request bodies; the sample client sets the content type based on the request file extension. New clients should use `POST /v1/package-operations/evaluate`; `POST /requests` remains as a simulator compatibility alias. + +## Runtime Integration Notes + +This artifact set designs and exercises the policy format; it does not add the actual elevated service. A runtime implementation should add signed or admin-protected policy locations, C# model classes, broker API contracts, audit logging, and unit tests around the same request and policy semantics. The elevated service should also reject stale or unsigned policy files before evaluating requests. \ No newline at end of file diff --git a/policies/csharp/.gitignore b/policies/csharp/.gitignore new file mode 100644 index 0000000000..3f3e86cb23 --- /dev/null +++ b/policies/csharp/.gitignore @@ -0,0 +1,4 @@ +bin/ +obj/ +**/bin/ +**/obj/ \ No newline at end of file diff --git a/policies/csharp/UniGetUI.PolicySimulator.Client/Program.cs b/policies/csharp/UniGetUI.PolicySimulator.Client/Program.cs new file mode 100644 index 0000000000..c44250682d --- /dev/null +++ b/policies/csharp/UniGetUI.PolicySimulator.Client/Program.cs @@ -0,0 +1,58 @@ +using System.Net; +using System.Net.Http.Headers; +using UniGetUI.PolicySimulator.Core; + +var parsedArgs = ArgumentParser.Parse(args); +var requestPath = parsedArgs.GetValueOrDefault("request") ?? throw new ArgumentException("Missing required --request argument."); +var server = parsedArgs.GetValueOrDefault("server") ?? "http://127.0.0.1:8765"; +var endpoint = parsedArgs.GetValueOrDefault("endpoint") ?? "/v1/package-operations/evaluate"; +var asJson = parsedArgs.ContainsKey("json"); + +var fullRequestPath = PolicyPathResolver.ResolveExistingPath(requestPath); +var requestText = await File.ReadAllTextAsync(fullRequestPath); +var format = DocumentLoader.InferFormatFromPath(fullRequestPath); +var contentType = format == "yaml" ? "application/x-yaml" : "application/vnd.unigetui.package-request+json; version=1.0"; + +using var client = new HttpClient(); +client.DefaultRequestHeaders.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/vnd.unigetui.package-broker-response+json; version=1.0")); +client.DefaultRequestHeaders.Add("UniGetUI-Protocol-Version", "1.0"); +using var content = new StringContent(requestText); +content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); + +var response = await client.PostAsync(new Uri(new Uri(server), endpoint), content); +var responseText = await response.Content.ReadAsStringAsync(); + +if (asJson) +{ + Console.WriteLine(responseText); +} +else +{ + Console.WriteLine($"HTTP {(int)response.StatusCode} {response.StatusCode}"); + Console.WriteLine(responseText); +} + +return response.StatusCode is HttpStatusCode.OK or HttpStatusCode.Forbidden or HttpStatusCode.BadRequest or HttpStatusCode.UnprocessableEntity ? 0 : 1; + +internal static class ArgumentParser +{ + public static Dictionary Parse(string[] args) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var index = 0; index < args.Length; index++) + { + var current = args[index]; + if (!current.StartsWith("--", StringComparison.Ordinal)) continue; + var key = current[2..]; + if (index + 1 >= args.Length || args[index + 1].StartsWith("--", StringComparison.Ordinal)) + { + result[key] = "true"; + continue; + } + + result[key] = args[++index]; + } + + return result; + } +} \ No newline at end of file diff --git a/policies/csharp/UniGetUI.PolicySimulator.Client/UniGetUI.PolicySimulator.Client.csproj b/policies/csharp/UniGetUI.PolicySimulator.Client/UniGetUI.PolicySimulator.Client.csproj new file mode 100644 index 0000000000..189519498c --- /dev/null +++ b/policies/csharp/UniGetUI.PolicySimulator.Client/UniGetUI.PolicySimulator.Client.csproj @@ -0,0 +1,12 @@ + + + Exe + net10.0 + enable + enable + latest + + + + + \ No newline at end of file diff --git a/policies/csharp/UniGetUI.PolicySimulator.Core/BrokerSimulator.cs b/policies/csharp/UniGetUI.PolicySimulator.Core/BrokerSimulator.cs new file mode 100644 index 0000000000..0437e1c656 --- /dev/null +++ b/policies/csharp/UniGetUI.PolicySimulator.Core/BrokerSimulator.cs @@ -0,0 +1,42 @@ +namespace UniGetUI.PolicySimulator.Core; + +public sealed class BrokerSimulator(PolicyDocument policy) +{ + private readonly PolicyEvaluator _evaluator = new(); + + public BrokerEvaluationResponse Evaluate(PackageRequest request) + { + try + { + var decision = _evaluator.Evaluate(policy, request); + var command = decision.Decision == "allow" ? CommandLineBuilder.Build(request) : []; + return new BrokerEvaluationResponse( + request.RequestId, + request.Manager.Name, + request.Source.Name, + request.Package.Id, + request.Operation, + decision.Decision, + decision.RuleId, + decision.Reason, + decision.Decision == "allow", + command, + "simulated-elevated"); + } + catch (Exception exception) when (exception is PolicyValidationException or InvalidOperationException) + { + return new BrokerEvaluationResponse( + request.RequestId, + request.Manager.Name, + request.Source.Name, + request.Package.Id, + request.Operation, + "deny", + "", + exception.Message, + false, + [], + "simulated-elevated"); + } + } +} \ No newline at end of file diff --git a/policies/csharp/UniGetUI.PolicySimulator.Core/CommandLineBuilder.cs b/policies/csharp/UniGetUI.PolicySimulator.Core/CommandLineBuilder.cs new file mode 100644 index 0000000000..901f4a7b7b --- /dev/null +++ b/policies/csharp/UniGetUI.PolicySimulator.Core/CommandLineBuilder.cs @@ -0,0 +1,63 @@ +namespace UniGetUI.PolicySimulator.Core; + +public static class CommandLineBuilder +{ + public static IReadOnlyList Build(PackageRequest request) + { + return request.Manager.Name switch + { + "Winget" => BuildWinget(request), + "PowerShell" => BuildPowerShell(request), + _ => throw new InvalidOperationException($"Unsupported manager '{request.Manager.Name}'.") + }; + } + + private static IReadOnlyList BuildWinget(PackageRequest request) + { + var operation = request.Operation switch + { + "install" => "install", + "update" => "upgrade", + "uninstall" => "uninstall", + _ => throw new InvalidOperationException($"Unsupported WinGet operation '{request.Operation}'.") + }; + + var command = new List { "winget.exe", operation, "--id", request.Package.Id, "--exact" }; + AddPair(command, "--source", request.Source.Name); + AddPair(command, "--scope", request.Options.Scope); + AddPair(command, "--version", request.Package.Version); + command.Add(request.Options.Interactive ? "--interactive" : "--silent"); + AddPair(command, "--architecture", request.Package.Architecture); + if (request.Options.SkipHashCheck) command.Add("--ignore-security-hash"); + AddPair(command, "--location", request.Options.CustomInstallLocation); + command.AddRange(request.Options.CustomParameters ?? []); + return command; + } + + private static IReadOnlyList BuildPowerShell(PackageRequest request) + { + var verb = request.Operation switch + { + "install" => "Install-Module", + "update" => "Update-Module", + "uninstall" => "Uninstall-Module", + _ => throw new InvalidOperationException($"Unsupported PowerShell operation '{request.Operation}'.") + }; + + var command = new List { "pwsh.exe", "-NoProfile", "-Command", verb, "-Name", request.Package.Id }; + if (request.Operation == "install" && request.Options.Scope == "user") command.AddRange(["-Scope", "CurrentUser"]); + if (request.Operation == "install" && request.Options.Scope == "machine") command.AddRange(["-Scope", "AllUsers"]); + AddPair(command, "-RequiredVersion", request.Package.Version); + if (request.Options.PreRelease) command.Add("-AllowPrerelease"); + if (request.Options.SkipHashCheck) command.Add("-SkipPublisherCheck"); + command.AddRange(request.Options.CustomParameters ?? []); + return command; + } + + private static void AddPair(List command, string name, string? value) + { + if (string.IsNullOrWhiteSpace(value)) return; + command.Add(name); + command.Add(value); + } +} \ No newline at end of file diff --git a/policies/csharp/UniGetUI.PolicySimulator.Core/DocumentLoader.cs b/policies/csharp/UniGetUI.PolicySimulator.Core/DocumentLoader.cs new file mode 100644 index 0000000000..6d7a63488e --- /dev/null +++ b/policies/csharp/UniGetUI.PolicySimulator.Core/DocumentLoader.cs @@ -0,0 +1,113 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Json.Schema; +using YamlDotNet.Serialization; + +namespace UniGetUI.PolicySimulator.Core; + +public sealed class DocumentLoader +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true + }; + + public LoadedDocument LoadFile(string path, string? schemaPath = null) + { + var fullPath = Path.GetFullPath(path); + var text = File.ReadAllText(fullPath); + return LoadText(text, fullPath, InferFormatFromPath(fullPath), schemaPath); + } + + public LoadedDocument LoadText(string text, string documentName, string format, string? schemaPath = null) + { + var canonicalJson = ConvertToCanonicalJson(text, format); + if (!string.IsNullOrWhiteSpace(schemaPath)) + { + ValidateJsonSchema(canonicalJson, schemaPath, documentName); + } + + var value = JsonSerializer.Deserialize(canonicalJson, JsonOptions) + ?? throw new PolicyValidationException($"Document '{documentName}' did not deserialize to {typeof(T).Name}."); + + return new LoadedDocument(documentName, format, canonicalJson, value); + } + + public static string InferFormatFromPath(string path) + { + var extension = Path.GetExtension(path).ToLowerInvariant(); + return extension switch + { + ".json" => "json", + ".yaml" or ".yml" => "yaml", + _ => throw new PolicyValidationException($"Unsupported document extension '{extension}'. Use .json, .yaml, or .yml.") + }; + } + + public static string InferFormatFromContentType(string? contentType) + { + if (contentType?.Contains("yaml", StringComparison.OrdinalIgnoreCase) == true || + contentType?.Contains("yml", StringComparison.OrdinalIgnoreCase) == true) + { + return "yaml"; + } + + return "json"; + } + + private static string ConvertToCanonicalJson(string text, string format) + { + if (format.Equals("json", StringComparison.OrdinalIgnoreCase)) + { + using var document = JsonDocument.Parse(text); + return JsonSerializer.Serialize(document.RootElement, JsonOptions); + } + + if (!format.Equals("yaml", StringComparison.OrdinalIgnoreCase)) + { + throw new PolicyValidationException($"Unsupported document format '{format}'."); + } + + var deserializer = new DeserializerBuilder() + .WithAttemptingUnquotedStringTypeDeserialization() + .Build(); + var yamlObject = deserializer.Deserialize(new StringReader(text)); + var normalized = NormalizeYamlObject(yamlObject); + return JsonSerializer.Serialize(normalized, JsonOptions); + } + + private static object? NormalizeYamlObject(object? value) + { + return value switch + { + null => null, + IDictionary dictionary => dictionary.ToDictionary( + item => Convert.ToString(item.Key, System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty, + item => NormalizeYamlObject(item.Value)), + IEnumerable list => list.Select(NormalizeYamlObject).ToList(), + _ => value + }; + } + + private static void ValidateJsonSchema(string canonicalJson, string schemaPath, string documentName) + { + var schemaText = File.ReadAllText(schemaPath); + var schema = JsonSchema.FromText(schemaText); + var instance = JsonNode.Parse(canonicalJson) + ?? throw new PolicyValidationException($"Document '{documentName}' could not be parsed as JSON."); + + var results = schema.Evaluate(instance, new EvaluationOptions { OutputFormat = OutputFormat.List }); + if (results.IsValid) + { + return; + } + + var details = results.Details + .Where(detail => detail.HasErrors) + .SelectMany(detail => detail.Errors?.Select(error => $"{detail.InstanceLocation}: {error.Key} {error.Value}") ?? []) + .ToList(); + + var message = details.Count == 0 ? "schema validation failed" : string.Join("; ", details); + throw new PolicyValidationException($"Document '{documentName}' failed schema validation: {message}"); + } +} \ No newline at end of file diff --git a/policies/csharp/UniGetUI.PolicySimulator.Core/PolicyEvaluator.cs b/policies/csharp/UniGetUI.PolicySimulator.Core/PolicyEvaluator.cs new file mode 100644 index 0000000000..1295d95d26 --- /dev/null +++ b/policies/csharp/UniGetUI.PolicySimulator.Core/PolicyEvaluator.cs @@ -0,0 +1,175 @@ +using System.Text.RegularExpressions; + +namespace UniGetUI.PolicySimulator.Core; + +public sealed class PolicyEvaluator +{ + public PolicyDecision Evaluate(PolicyDocument policy, PackageRequest request) + { + ValidatePolicyShape(policy); + ValidateRequestShape(request); + + var matchedRules = new List(); + foreach (var rule in policy.Rules) + { + if (rule.Enabled == false) + { + continue; + } + + if (RuleMatches(rule, request)) + { + matchedRules.Add(new MatchedRule(rule.Id, rule.Priority, rule.Decision, rule.Reason)); + } + } + + if (matchedRules.Count == 0) + { + return new PolicyDecision( + policy.Enforcement.DefaultDecision, + "", + null, + $"No enabled rule matched; using defaultDecision '{policy.Enforcement.DefaultDecision}'.", + []); + } + + var winner = matchedRules + .OrderBy(rule => rule.Priority) + .ThenBy(rule => rule.Decision.Equals("deny", StringComparison.OrdinalIgnoreCase) ? 0 : 1) + .First(); + + return new PolicyDecision(winner.Decision, winner.Id, winner.Priority, winner.Reason ?? "Rule matched.", matchedRules); + } + + public static void ValidatePolicyShape(PolicyDocument policy) + { + if (policy.PolicyType != "packageBrokerPolicy") throw new PolicyValidationException("Policy field 'policyType' must be 'packageBrokerPolicy'."); + if (string.IsNullOrWhiteSpace(policy.PolicyVersion)) throw new PolicyValidationException("Policy field 'policyVersion' is required."); + if (string.IsNullOrWhiteSpace(policy.Metadata.Id)) throw new PolicyValidationException("Policy field 'metadata.id' is required."); + if (policy.Enforcement.FailureDecision != "deny") throw new PolicyValidationException("Policy field 'enforcement.failureDecision' must be 'deny'."); + if (policy.Enforcement.DefaultDecision is not ("allow" or "deny")) throw new PolicyValidationException("Policy field 'enforcement.defaultDecision' must be 'allow' or 'deny'."); + if (policy.Enforcement.RulePrecedence != "priorityThenDeny") throw new PolicyValidationException("Policy field 'enforcement.rulePrecedence' must be 'priorityThenDeny'."); + if (policy.Rules.Count == 0) throw new PolicyValidationException("Policy field 'rules' must contain at least one rule."); + } + + public static void ValidateRequestShape(PackageRequest request) + { + if (request.RequestType != "packageOperation") throw new PolicyValidationException("Request field 'requestType' must be 'packageOperation'."); + if (string.IsNullOrWhiteSpace(request.RequestVersion)) throw new PolicyValidationException("Request field 'requestVersion' is required."); + if (string.IsNullOrWhiteSpace(request.RequestId)) throw new PolicyValidationException("Request field 'requestId' is required."); + if (request.Operation is not ("install" or "update" or "uninstall")) throw new PolicyValidationException($"Request operation '{request.Operation}' is not supported."); + if (request.Manager.Name is not ("Winget" or "PowerShell")) throw new PolicyValidationException("Request manager.name must be 'Winget' or 'PowerShell'."); + if (string.IsNullOrWhiteSpace(request.Source.Name)) throw new PolicyValidationException("Request source.name is required."); + if (string.IsNullOrWhiteSpace(request.Package.Id)) throw new PolicyValidationException("Request package.id is required."); + if (string.IsNullOrWhiteSpace(request.Package.Name)) throw new PolicyValidationException("Request package.name is required."); + if (request.Broker.RequestedElevation is not ("standard" or "elevated")) throw new PolicyValidationException("Request broker.requestedElevation must be 'standard' or 'elevated'."); + } + + private static bool RuleMatches(PolicyRule rule, PackageRequest request) + { + var flags = RequestFlags.FromRequest(request); + var effectiveVersion = GetEffectiveVersion(request); + + return ValueInList(request.Operation, rule.Match.Operations) && + ValueInList(request.Manager.Name, rule.Match.Managers) && + WildcardAny(request.Source.Name, rule.Match.Sources) && + WildcardAny(request.Package.Id, rule.Match.PackageIdentifiers) && + WildcardAny(request.Package.Name, rule.Match.PackageNames) && + ValueInList(effectiveVersion, rule.Match.Versions) && + VersionRangeMatches(effectiveVersion, rule.Match.VersionRange) && + ValueInList(request.Options.Scope, rule.Match.Scopes) && + ValueInList(request.Package.Architecture, rule.Match.Architectures) && + ValueInList(request.Broker.RequestedElevation, rule.Match.Elevation) && + ValueInList(request.Options.Interactive, rule.Match.Interactive) && + ValueInList(request.Options.SkipHashCheck, rule.Match.SkipHashCheck) && + ValueInList(request.Options.PreRelease, rule.Match.PreRelease) && + ValueInList(flags.HasCustomParameters, rule.Match.HasCustomParameters) && + ValueInList(flags.HasCustomInstallLocation, rule.Match.HasCustomInstallLocation) && + ValueInList(flags.HasPrePostCommands, rule.Match.HasPrePostCommands) && + ValueInList(flags.HasKillBeforeOperation, rule.Match.HasKillBeforeOperation) && + ConstraintsPass(rule.Constraints, request, flags); + } + + private static bool ConstraintsPass(PolicyConstraints? constraints, PackageRequest request, RequestFlags flags) + { + if (constraints is null) return true; + if (constraints.AllowInteractive == false && request.Options.Interactive) return false; + if (constraints.AllowSkipHashCheck == false && request.Options.SkipHashCheck) return false; + if (constraints.AllowPreRelease == false && request.Options.PreRelease) return false; + if (constraints.AllowCustomInstallLocation == false && flags.HasCustomInstallLocation) return false; + if (constraints.AllowCustomParameters == false && flags.HasCustomParameters) return false; + if (constraints.AllowPrePostCommands == false && flags.HasPrePostCommands) return false; + if (constraints.AllowKillBeforeOperation == false && flags.HasKillBeforeOperation) return false; + + if (flags.HasCustomInstallLocation && constraints.AllowedInstallLocationPatterns is not null && !WildcardAny(flags.CustomInstallLocation, constraints.AllowedInstallLocationPatterns)) + { + return false; + } + + foreach (var parameter in flags.CustomParameters) + { + if (constraints.DeniedCustomParameters is not null && WildcardAny(parameter, constraints.DeniedCustomParameters)) return false; + if (constraints.AllowedCustomParameters is not null || constraints.AllowedCustomParameterPatterns is not null) + { + var exactAllowed = ValueInList(parameter, constraints.AllowedCustomParameters); + var patternAllowed = WildcardAny(parameter, constraints.AllowedCustomParameterPatterns); + if (!exactAllowed && !patternAllowed) return false; + } + } + + return true; + } + + private static bool ValueInList(T? value, IReadOnlyCollection? list) + { + return list is null || list.Contains(value!); + } + + private static bool WildcardAny(string? value, IReadOnlyCollection? patterns) + { + if (patterns is null) return true; + if (value is null) return false; + return patterns.Any(pattern => Regex.IsMatch(value, "^" + Regex.Escape(pattern).Replace("\\*", ".*") + "$", RegexOptions.IgnoreCase)); + } + + private static string GetEffectiveVersion(PackageRequest request) + { + if (!string.IsNullOrWhiteSpace(request.Package.Version)) return request.Package.Version; + return string.Empty; + } + + private static bool VersionRangeMatches(string version, VersionRange? range) + { + if (range is null) return true; + if (string.IsNullOrWhiteSpace(version)) return false; + if (version.Contains('-', StringComparison.Ordinal) && !range.IncludePrerelease) return false; + if (!string.IsNullOrWhiteSpace(range.MinVersion) && CompareVersions(version, range.MinVersion) < 0) return false; + if (!string.IsNullOrWhiteSpace(range.MaxVersion) && CompareVersions(version, range.MaxVersion) > 0) return false; + return true; + } + + private static int CompareVersions(string left, string right) + { + var normalizedLeft = left.Split('-', 2)[0]; + var normalizedRight = right.Split('-', 2)[0]; + return Version.TryParse(normalizedLeft, out var leftVersion) && Version.TryParse(normalizedRight, out var rightVersion) + ? leftVersion.CompareTo(rightVersion) + : string.Compare(left, right, StringComparison.OrdinalIgnoreCase); + } + + private sealed record RequestFlags(bool HasCustomParameters, bool HasCustomInstallLocation, bool HasPrePostCommands, bool HasKillBeforeOperation, IReadOnlyList CustomParameters, string CustomInstallLocation) + { + public static RequestFlags FromRequest(PackageRequest request) + { + var customParameters = request.Options.CustomParameters ?? []; + var customInstallLocation = request.Options.CustomInstallLocation ?? string.Empty; + return new RequestFlags( + customParameters.Count > 0, + !string.IsNullOrWhiteSpace(customInstallLocation), + !string.IsNullOrWhiteSpace(request.Options.PreOperationCommand) || !string.IsNullOrWhiteSpace(request.Options.PostOperationCommand), + request.Options.KillBeforeOperation?.Count > 0, + customParameters, + customInstallLocation); + } + } +} \ No newline at end of file diff --git a/policies/csharp/UniGetUI.PolicySimulator.Core/PolicyModels.cs b/policies/csharp/UniGetUI.PolicySimulator.Core/PolicyModels.cs new file mode 100644 index 0000000000..96f4a2aaed --- /dev/null +++ b/policies/csharp/UniGetUI.PolicySimulator.Core/PolicyModels.cs @@ -0,0 +1,298 @@ +using System.Text.Json.Serialization; + +namespace UniGetUI.PolicySimulator.Core; + +public sealed class PolicyDocument +{ + [JsonPropertyName("policyVersion")] + public string PolicyVersion { get; set; } = ""; + + [JsonPropertyName("policyType")] + public string PolicyType { get; set; } = ""; + + [JsonPropertyName("metadata")] + public PolicyMetadata Metadata { get; set; } = new(); + + [JsonPropertyName("enforcement")] + public PolicyEnforcement Enforcement { get; set; } = new(); + + [JsonPropertyName("rules")] + public List Rules { get; set; } = []; +} + +public sealed class PolicyMetadata +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("publisher")] + public string Publisher { get; set; } = ""; + + [JsonPropertyName("revision")] + public int Revision { get; set; } + + [JsonPropertyName("publishedAt")] + public string PublishedAt { get; set; } = ""; +} + +public sealed class PolicyEnforcement +{ + [JsonPropertyName("defaultDecision")] + public string DefaultDecision { get; set; } = "deny"; + + [JsonPropertyName("failureDecision")] + public string FailureDecision { get; set; } = "deny"; + + [JsonPropertyName("rulePrecedence")] + public string RulePrecedence { get; set; } = "priorityThenDeny"; +} + +public sealed class PolicyRule +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("enabled")] + public bool? Enabled { get; set; } + + [JsonPropertyName("priority")] + public int Priority { get; set; } + + [JsonPropertyName("decision")] + public string Decision { get; set; } = "deny"; + + [JsonPropertyName("reason")] + public string? Reason { get; set; } + + [JsonPropertyName("match")] + public PolicyMatch Match { get; set; } = new(); + + [JsonPropertyName("constraints")] + public PolicyConstraints? Constraints { get; set; } +} + +public sealed class PolicyMatch +{ + [JsonPropertyName("operations")] + public List? Operations { get; set; } + + [JsonPropertyName("managers")] + public List? Managers { get; set; } + + [JsonPropertyName("sources")] + public List? Sources { get; set; } + + [JsonPropertyName("packageIdentifiers")] + public List? PackageIdentifiers { get; set; } + + [JsonPropertyName("packageNames")] + public List? PackageNames { get; set; } + + [JsonPropertyName("versions")] + public List? Versions { get; set; } + + [JsonPropertyName("versionRange")] + public VersionRange? VersionRange { get; set; } + + [JsonPropertyName("scopes")] + public List? Scopes { get; set; } + + [JsonPropertyName("architectures")] + public List? Architectures { get; set; } + + [JsonPropertyName("elevation")] + public List? Elevation { get; set; } + + [JsonPropertyName("interactive")] + public List? Interactive { get; set; } + + [JsonPropertyName("skipHashCheck")] + public List? SkipHashCheck { get; set; } + + [JsonPropertyName("preRelease")] + public List? PreRelease { get; set; } + + [JsonPropertyName("hasCustomParameters")] + public List? HasCustomParameters { get; set; } + + [JsonPropertyName("hasCustomInstallLocation")] + public List? HasCustomInstallLocation { get; set; } + + [JsonPropertyName("hasPrePostCommands")] + public List? HasPrePostCommands { get; set; } + + [JsonPropertyName("hasKillBeforeOperation")] + public List? HasKillBeforeOperation { get; set; } +} + +public sealed class VersionRange +{ + [JsonPropertyName("minVersion")] + public string? MinVersion { get; set; } + + [JsonPropertyName("maxVersion")] + public string? MaxVersion { get; set; } + + [JsonPropertyName("includePrerelease")] + public bool IncludePrerelease { get; set; } +} + +public sealed class PolicyConstraints +{ + [JsonPropertyName("allowInteractive")] + public bool? AllowInteractive { get; set; } + + [JsonPropertyName("allowSkipHashCheck")] + public bool? AllowSkipHashCheck { get; set; } + + [JsonPropertyName("allowPreRelease")] + public bool? AllowPreRelease { get; set; } + + [JsonPropertyName("allowCustomInstallLocation")] + public bool? AllowCustomInstallLocation { get; set; } + + [JsonPropertyName("allowedInstallLocationPatterns")] + public List? AllowedInstallLocationPatterns { get; set; } + + [JsonPropertyName("allowCustomParameters")] + public bool? AllowCustomParameters { get; set; } + + [JsonPropertyName("allowedCustomParameters")] + public List? AllowedCustomParameters { get; set; } + + [JsonPropertyName("allowedCustomParameterPatterns")] + public List? AllowedCustomParameterPatterns { get; set; } + + [JsonPropertyName("deniedCustomParameters")] + public List? DeniedCustomParameters { get; set; } + + [JsonPropertyName("allowPrePostCommands")] + public bool? AllowPrePostCommands { get; set; } + + [JsonPropertyName("allowKillBeforeOperation")] + public bool? AllowKillBeforeOperation { get; set; } +} + +public sealed class PackageRequest +{ + [JsonPropertyName("requestVersion")] + public string RequestVersion { get; set; } = ""; + + [JsonPropertyName("requestType")] + public string RequestType { get; set; } = ""; + + [JsonPropertyName("requestId")] + public string RequestId { get; set; } = ""; + + [JsonPropertyName("createdAt")] + public string CreatedAt { get; set; } = ""; + + [JsonPropertyName("operation")] + public string Operation { get; set; } = ""; + + [JsonPropertyName("manager")] + public RequestManager Manager { get; set; } = new(); + + [JsonPropertyName("source")] + public RequestSource Source { get; set; } = new(); + + [JsonPropertyName("package")] + public RequestPackage Package { get; set; } = new(); + + [JsonPropertyName("options")] + public RequestOptions Options { get; set; } = new(); + + [JsonPropertyName("broker")] + public BrokerContext Broker { get; set; } = new(); +} + +public sealed class RequestManager +{ + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = ""; + + [JsonPropertyName("executableFriendlyName")] + public string ExecutableFriendlyName { get; set; } = ""; +} + +public sealed class RequestSource +{ + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("url")] + public string? Url { get; set; } + + [JsonPropertyName("isVirtualManager")] + public bool? IsVirtualManager { get; set; } +} + +public sealed class RequestPackage +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("version")] + public string? Version { get; set; } + + [JsonPropertyName("architecture")] + public string? Architecture { get; set; } + + [JsonPropertyName("channel")] + public string? Channel { get; set; } +} + +public sealed class RequestOptions +{ + [JsonPropertyName("scope")] + public string? Scope { get; set; } + + [JsonPropertyName("interactive")] + public bool Interactive { get; set; } + + [JsonPropertyName("skipHashCheck")] + public bool SkipHashCheck { get; set; } + + [JsonPropertyName("preRelease")] + public bool PreRelease { get; set; } + + [JsonPropertyName("customInstallLocation")] + public string? CustomInstallLocation { get; set; } + + [JsonPropertyName("customParameters")] + public List? CustomParameters { get; set; } + + [JsonPropertyName("preOperationCommand")] + public string? PreOperationCommand { get; set; } + + [JsonPropertyName("postOperationCommand")] + public string? PostOperationCommand { get; set; } + + [JsonPropertyName("killBeforeOperation")] + public List? KillBeforeOperation { get; set; } +} + +public sealed class BrokerContext +{ + [JsonPropertyName("requestedElevation")] + public string RequestedElevation { get; set; } = ""; + + [JsonPropertyName("effectiveUser")] + public string EffectiveUser { get; set; } = ""; + + [JsonPropertyName("clientVersion")] + public string? ClientVersion { get; set; } +} + +public sealed record LoadedDocument(string Path, string Format, string CanonicalJson, T Value); +public sealed record MatchedRule(string Id, int Priority, string Decision, string? Reason); +public sealed record PolicyDecision(string Decision, string RuleId, int? Priority, string Reason, IReadOnlyList MatchedRules); +public sealed record BrokerEvaluationResponse(string RequestId, string? Manager, string? Source, string? PackageId, string? Operation, string Decision, string RuleId, string Reason, bool WouldExecute, IReadOnlyList Command, string Mode); + +public sealed class PolicyValidationException(string message) : Exception(message); \ No newline at end of file diff --git a/policies/csharp/UniGetUI.PolicySimulator.Core/PolicyPathResolver.cs b/policies/csharp/UniGetUI.PolicySimulator.Core/PolicyPathResolver.cs new file mode 100644 index 0000000000..e230c4ed2e --- /dev/null +++ b/policies/csharp/UniGetUI.PolicySimulator.Core/PolicyPathResolver.cs @@ -0,0 +1,60 @@ +namespace UniGetUI.PolicySimulator.Core; + +public static class PolicyPathResolver +{ + public static string ResolveExistingPath(string path) + { + if (Path.IsPathRooted(path)) + { + return Path.GetFullPath(path); + } + + foreach (var basePath in GetCandidateBasePaths()) + { + foreach (var ancestor in EnumerateAncestors(basePath)) + { + var candidate = Path.GetFullPath(Path.Combine(ancestor, path)); + if (File.Exists(candidate) || Directory.Exists(candidate)) + { + return candidate; + } + } + } + + return Path.GetFullPath(path); + } + + public static string FindPoliciesRoot() + { + foreach (var basePath in GetCandidateBasePaths()) + { + foreach (var ancestor in EnumerateAncestors(basePath)) + { + var policySchemaPath = Path.Combine(ancestor, "schemas", "unigetui.package-policy.schema.1.0.json"); + var requestSchemaPath = Path.Combine(ancestor, "schemas", "unigetui.package-request.schema.1.0.json"); + if (File.Exists(policySchemaPath) && File.Exists(requestSchemaPath)) + { + return ancestor; + } + } + } + + throw new DirectoryNotFoundException("Could not locate the policies root containing the schema files."); + } + + private static IEnumerable GetCandidateBasePaths() + { + yield return Directory.GetCurrentDirectory(); + yield return AppContext.BaseDirectory; + } + + private static IEnumerable EnumerateAncestors(string path) + { + var directory = new DirectoryInfo(Path.GetFullPath(path)); + while (directory is not null) + { + yield return directory.FullName; + directory = directory.Parent; + } + } +} \ No newline at end of file diff --git a/policies/csharp/UniGetUI.PolicySimulator.Core/UniGetUI.PolicySimulator.Core.csproj b/policies/csharp/UniGetUI.PolicySimulator.Core/UniGetUI.PolicySimulator.Core.csproj new file mode 100644 index 0000000000..a42503fe72 --- /dev/null +++ b/policies/csharp/UniGetUI.PolicySimulator.Core/UniGetUI.PolicySimulator.Core.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + enable + latest + + + + + + \ No newline at end of file diff --git a/policies/csharp/UniGetUI.PolicySimulator.Server/Program.cs b/policies/csharp/UniGetUI.PolicySimulator.Server/Program.cs new file mode 100644 index 0000000000..256d63912c --- /dev/null +++ b/policies/csharp/UniGetUI.PolicySimulator.Server/Program.cs @@ -0,0 +1,219 @@ +using System.Text.Json; +using UniGetUI.PolicySimulator.Core; + +const string ProtocolVersion = "1.0"; +const string RequestMediaType = "application/vnd.unigetui.package-request+json; version=1.0"; +const string ResponseMediaType = "application/vnd.unigetui.package-broker-response+json; version=1.0"; + +var parsedArgs = ArgumentParser.Parse(args); +var policiesRoot = PolicyPathResolver.FindPoliciesRoot(); +var policyPath = PolicyPathResolver.ResolveExistingPath(parsedArgs.GetValueOrDefault("policy") ?? throw new ArgumentException("Missing required --policy argument.")); +var policySchemaPath = parsedArgs.TryGetValue("policy-schema", out var policySchemaArgument) ? PolicyPathResolver.ResolveExistingPath(policySchemaArgument) : Path.Combine(policiesRoot, "schemas", "unigetui.package-policy.schema.1.0.json"); +var requestSchemaPath = parsedArgs.TryGetValue("request-schema", out var requestSchemaArgument) ? PolicyPathResolver.ResolveExistingPath(requestSchemaArgument) : Path.Combine(policiesRoot, "schemas", "unigetui.package-request.schema.1.0.json"); +var url = parsedArgs.GetValueOrDefault("url") ?? "http://127.0.0.1:8765"; + +var loader = new DocumentLoader(); +var loadedPolicy = loader.LoadFile(policyPath, policySchemaPath); +PolicyEvaluator.ValidatePolicyShape(loadedPolicy.Value); +var broker = new BrokerSimulator(loadedPolicy.Value); + +var builder = WebApplication.CreateBuilder(args); +builder.WebHost.UseUrls(url); + +var app = builder.Build(); + +app.MapGet("/health", Health); +app.MapGet("/v1/health", Health); +app.MapGet("/v1/capabilities", Capabilities); +app.MapPost("/requests", HandlePackageOperation); +app.MapPost("/v1/package-operations/evaluate", HandlePackageOperation); + +Console.WriteLine($"UniGetUI C# policy server simulator listening on {url}"); +Console.WriteLine($"Policy: {policyPath}"); +await app.RunAsync(); + +IResult Health() +{ + return Results.Json(new + { + status = "ready", + protocolVersion = ProtocolVersion, + elevatedSimulation = true, + policyPath, + endpoints = new[] { "GET /v1/health", "GET /v1/capabilities", "POST /v1/package-operations/evaluate", "POST /requests" } + }); +} + +IResult Capabilities() +{ + return Results.Json(new + { + protocolVersion = ProtocolVersion, + transports = new[] { "http-loopback-simulator", "http-named-pipe" }, + requestMediaTypes = new[] { RequestMediaType, "application/json" }, + responseMediaTypes = new[] { ResponseMediaType }, + requestSchema = "https://aka.ms/unigetui/package-request.schema.1.0.json", + responseSchema = "https://aka.ms/unigetui/package-broker-response.schema.1.0.json", + supportedManagers = new[] { "Winget", "PowerShell" }, + supportedOperations = new[] { "install", "update", "uninstall" }, + maxRequestBodyBytes = 262144 + }); +} + +async Task HandlePackageOperation(HttpRequest httpRequest) +{ + var auditId = CreateAuditId(); + using var reader = new StreamReader(httpRequest.Body); + var body = await reader.ReadToEndAsync(); + if (string.IsNullOrWhiteSpace(body)) + { + AddProtocolHeaders(httpRequest.HttpContext.Response, null, auditId, loadedPolicy.Value); + return Results.Json(ToValidationFailureEnvelope(loadedPolicy.Value, auditId, "Request body is required."), contentType: ResponseMediaType, statusCode: StatusCodes.Status400BadRequest); + } + + try + { + var format = DocumentLoader.InferFormatFromContentType(httpRequest.ContentType); + var request = loader.LoadText(body, "HTTP request body", format, requestSchemaPath).Value; + ValidateRequestHeaders(httpRequest, request); + var response = broker.Evaluate(request); + AddProtocolHeaders(httpRequest.HttpContext.Response, response.RequestId, auditId, loadedPolicy.Value); + var statusCode = response.Decision == "allow" ? StatusCodes.Status200OK : StatusCodes.Status403Forbidden; + return Results.Json(ToEnvelope(response, loadedPolicy.Value, auditId), contentType: ResponseMediaType, statusCode: statusCode); + } + catch (Exception exception) when (exception is PolicyValidationException or JsonException) + { + AddProtocolHeaders(httpRequest.HttpContext.Response, null, auditId, loadedPolicy.Value); + return Results.Json(ToValidationFailureEnvelope(loadedPolicy.Value, auditId, exception.Message), contentType: ResponseMediaType, statusCode: StatusCodes.Status422UnprocessableEntity); + } +} + +static void ValidateRequestHeaders(HttpRequest httpRequest, PackageRequest request) +{ + if (httpRequest.Headers.TryGetValue("UniGetUI-Request-Id", out var requestIdHeader) && requestIdHeader.Count > 0 && requestIdHeader[0] != request.RequestId) + { + throw new PolicyValidationException("Header 'UniGetUI-Request-Id' must match request body field 'requestId'."); + } +} + +static void AddProtocolHeaders(HttpResponse response, string? requestId, string auditId, PolicyDocument policy) +{ + response.Headers["UniGetUI-Protocol-Version"] = ProtocolVersion; + response.Headers["UniGetUI-Audit-Id"] = auditId; + response.Headers["UniGetUI-Policy-Id"] = policy.Metadata.Id; + response.Headers["UniGetUI-Policy-Revision"] = policy.Metadata.Revision.ToString(System.Globalization.CultureInfo.InvariantCulture); + if (!string.IsNullOrWhiteSpace(requestId)) + { + response.Headers["UniGetUI-Request-Id"] = requestId; + } +} + +static object ToEnvelope(BrokerEvaluationResponse response, PolicyDocument policy, string auditId) +{ + var timestamp = DateTimeOffset.UtcNow; + return new + { + responseVersion = "1.0.0", + responseType = "packageBrokerResponse", + broker = new + { + name = "UniGetUI C# policy server simulator", + protocolVersion = ProtocolVersion, + transport = "http-loopback-simulator", + elevatedSimulation = true + }, + auditId, + receivedAt = timestamp, + completedAt = timestamp, + response.RequestId, + response.Manager, + response.Source, + response.PackageId, + response.Operation, + response.Decision, + response.RuleId, + response.Reason, + response.WouldExecute, + policy = new + { + id = policy.Metadata.Id, + revision = policy.Metadata.Revision, + policyVersion = policy.PolicyVersion + }, + execution = new + { + response.Mode, + Command = response.Command, + note = "The sample server returns the command that an elevated broker would run; it does not execute package managers." + } + }; +} + +static object ToValidationFailureEnvelope(PolicyDocument policy, string auditId, string reason) +{ + var timestamp = DateTimeOffset.UtcNow; + return new + { + responseVersion = "1.0.0", + responseType = "packageBrokerResponse", + broker = new + { + name = "UniGetUI C# policy server simulator", + protocolVersion = ProtocolVersion, + transport = "http-loopback-simulator", + elevatedSimulation = true + }, + auditId, + requestId = (string?)null, + receivedAt = timestamp, + completedAt = timestamp, + manager = (string?)null, + source = (string?)null, + packageId = (string?)null, + operation = (string?)null, + decision = "deny", + ruleId = "", + reason, + wouldExecute = false, + policy = new + { + id = policy.Metadata.Id, + revision = policy.Metadata.Revision, + policyVersion = policy.PolicyVersion + }, + execution = new + { + mode = "simulated-elevated", + command = Array.Empty(), + note = "The sample server validates and filters requests but never executes package managers." + } + }; +} + +static string CreateAuditId() +{ + return "audit-" + Guid.NewGuid().ToString("N"); +} + +internal static class ArgumentParser +{ + public static Dictionary Parse(string[] args) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var index = 0; index < args.Length; index++) + { + var current = args[index]; + if (!current.StartsWith("--", StringComparison.Ordinal)) continue; + var key = current[2..]; + if (index + 1 >= args.Length || args[index + 1].StartsWith("--", StringComparison.Ordinal)) + { + result[key] = "true"; + continue; + } + + result[key] = args[++index]; + } + + return result; + } +} \ No newline at end of file diff --git a/policies/csharp/UniGetUI.PolicySimulator.Server/Properties/launchSettings.json b/policies/csharp/UniGetUI.PolicySimulator.Server/Properties/launchSettings.json new file mode 100644 index 0000000000..272a6a448e --- /dev/null +++ b/policies/csharp/UniGetUI.PolicySimulator.Server/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "UniGetUI.PolicySimulator.Server": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:54548;http://localhost:54549" + } + } +} \ No newline at end of file diff --git a/policies/csharp/UniGetUI.PolicySimulator.Server/UniGetUI.PolicySimulator.Server.csproj b/policies/csharp/UniGetUI.PolicySimulator.Server/UniGetUI.PolicySimulator.Server.csproj new file mode 100644 index 0000000000..5cb17d4e0b --- /dev/null +++ b/policies/csharp/UniGetUI.PolicySimulator.Server/UniGetUI.PolicySimulator.Server.csproj @@ -0,0 +1,11 @@ + + + net10.0 + enable + enable + latest + + + + + \ No newline at end of file diff --git a/policies/csharp/UniGetUI.PolicySimulator.Tests/Program.cs b/policies/csharp/UniGetUI.PolicySimulator.Tests/Program.cs new file mode 100644 index 0000000000..da7398b296 --- /dev/null +++ b/policies/csharp/UniGetUI.PolicySimulator.Tests/Program.cs @@ -0,0 +1,164 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using UniGetUI.PolicySimulator.Core; + +var parsedArgs = ArgumentParser.Parse(args); +var policyRoot = parsedArgs.TryGetValue("policy-root", out var policyRootArgument) + ? PolicyPathResolver.ResolveExistingPath(policyRootArgument) + : PolicyPathResolver.FindPoliciesRoot(); +var samplesRoot = Path.Combine(policyRoot, "samples"); +var policySchemaPath = Path.Combine(policyRoot, "schemas", "unigetui.package-policy.schema.json"); +var requestSchemaPath = Path.Combine(policyRoot, "schemas", "unigetui.package-request.schema.json"); +var scenarioRoot = Path.Combine(samplesRoot, "scenarios"); +var loader = new DocumentLoader(); +var evaluator = new PolicyEvaluator(); +var failures = new List(); +var passed = 0; +var commandChecksPassed = 0; +var commandChecksTotal = 0; + +foreach (var manifestPath in Directory.EnumerateFiles(scenarioRoot, "*.scenarios.json").OrderBy(path => path, StringComparer.OrdinalIgnoreCase)) +{ + var manifest = JsonSerializer.Deserialize(await File.ReadAllTextAsync(manifestPath), new JsonSerializerOptions(JsonSerializerDefaults.Web)) + ?? throw new InvalidOperationException($"Could not parse scenario manifest '{manifestPath}'."); + + foreach (var scenario in manifest.Scenarios) + { + var policyPath = Path.Combine(samplesRoot, scenario.Policy); + var requestPath = Path.Combine(samplesRoot, scenario.Request); + var response = EvaluateScenario(loader, evaluator, policyPath, requestPath, policySchemaPath, requestSchemaPath); + var decisionPassed = response.Decision == scenario.ExpectedDecision; + var rulePassed = string.IsNullOrWhiteSpace(scenario.ExpectedRuleId) || response.RuleId == scenario.ExpectedRuleId; + if (decisionPassed && rulePassed) + { + passed++; + continue; + } + + failures.Add($"{scenario.Id}: expected {scenario.ExpectedDecision}/{scenario.ExpectedRuleId}, got {response.Decision}/{response.RuleId}. Reason: {response.Reason}"); + } +} + +commandChecksPassed = RunCommandConstructionChecks(loader, samplesRoot, requestSchemaPath, failures, out commandChecksTotal); + +foreach (var failure in failures) +{ + Console.Error.WriteLine(failure); +} + +Console.WriteLine($"Scenario checks passed: {passed}"); +Console.WriteLine($"Command construction checks passed: {commandChecksPassed} of {commandChecksTotal}"); +return failures.Count == 0 ? 0 : 1; + +static BrokerEvaluationResponse EvaluateScenario(DocumentLoader loader, PolicyEvaluator evaluator, string policyPath, string requestPath, string policySchemaPath, string requestSchemaPath) +{ + try + { + var policy = loader.LoadFile(policyPath, policySchemaPath).Value; + var request = loader.LoadFile(requestPath, requestSchemaPath).Value; + var decision = evaluator.Evaluate(policy, request); + var command = decision.Decision == "allow" ? CommandLineBuilder.Build(request) : []; + return new BrokerEvaluationResponse(request.RequestId, request.Manager.Name, request.Source.Name, request.Package.Id, request.Operation, decision.Decision, decision.RuleId, decision.Reason, decision.Decision == "allow", command, "simulated-elevated"); + } + catch (Exception exception) when (exception is PolicyValidationException or JsonException) + { + return new BrokerEvaluationResponse("", null, null, null, null, "deny", "", exception.Message, false, [], "simulated-elevated"); + } +} + +static int RunCommandConstructionChecks(DocumentLoader loader, string samplesRoot, string requestSchemaPath, List failures, out int total) +{ + var checks = new[] + { + new CommandCheck( + "WinGet install", + Path.Combine(samplesRoot, "requests", "winget-vscode-install.request.json"), + ["winget.exe", "install", "--id", "Microsoft.VisualStudioCode", "--exact", "--source", "winget", "--scope", "machine", "--silent", "--architecture", "x64"]), + new CommandCheck( + "WinGet update maps to upgrade", + Path.Combine(samplesRoot, "requests", "winget-vscode-update-in-range.request.json"), + ["winget.exe", "upgrade", "--id", "Microsoft.VisualStudioCode", "--exact", "--source", "winget", "--scope", "machine", "--version", "1.96.0", "--silent", "--architecture", "x64"]), + new CommandCheck( + "WinGet uninstall", + Path.Combine(samplesRoot, "requests", "winget-git-uninstall.request.json"), + ["winget.exe", "uninstall", "--id", "Git.Git", "--exact", "--source", "winget", "--scope", "machine", "--silent", "--architecture", "x64"]), + new CommandCheck( + "PowerShell install", + Path.Combine(samplesRoot, "requests", "powershell-pester-currentuser.request.json"), + ["pwsh.exe", "-NoProfile", "-Command", "Install-Module", "-Name", "Pester", "-Scope", "CurrentUser"]), + new CommandCheck( + "PowerShell versioned install", + Path.Combine(samplesRoot, "requests", "powershell-pester-version-allowed.request.json"), + ["pwsh.exe", "-NoProfile", "-Command", "Install-Module", "-Name", "Pester", "-Scope", "CurrentUser", "-RequiredVersion", "5.5.0"]), + new CommandCheck( + "PowerShell update", + Path.Combine(samplesRoot, "requests", "powershell-pester-update-currentuser.request.json"), + ["pwsh.exe", "-NoProfile", "-Command", "Update-Module", "-Name", "Pester", "-RequiredVersion", "5.5.0"]) + }; + + total = checks.Length; + var passed = 0; + foreach (var check in checks) + { + var request = loader.LoadFile(check.RequestPath, requestSchemaPath).Value; + var command = CommandLineBuilder.Build(request); + if (!command.SequenceEqual(check.ExpectedCommand)) + { + failures.Add($"{check.Name} command mismatch: {string.Join(' ', command)}"); + continue; + } + + passed++; + } + + return passed; +} + +internal sealed record CommandCheck(string Name, string RequestPath, IReadOnlyList ExpectedCommand); + +internal sealed class ScenarioManifest +{ + [JsonPropertyName("scenarios")] + public List Scenarios { get; set; } = []; +} + +internal sealed class Scenario +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("policy")] + public string Policy { get; set; } = ""; + + [JsonPropertyName("request")] + public string Request { get; set; } = ""; + + [JsonPropertyName("expectedDecision")] + public string ExpectedDecision { get; set; } = "deny"; + + [JsonPropertyName("expectedRuleId")] + public string? ExpectedRuleId { get; set; } +} + +internal static class ArgumentParser +{ + public static Dictionary Parse(string[] args) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var index = 0; index < args.Length; index++) + { + var current = args[index]; + if (!current.StartsWith("--", StringComparison.Ordinal)) continue; + var key = current[2..]; + if (index + 1 >= args.Length || args[index + 1].StartsWith("--", StringComparison.Ordinal)) + { + result[key] = "true"; + continue; + } + + result[key] = args[++index]; + } + + return result; + } +} \ No newline at end of file diff --git a/policies/csharp/UniGetUI.PolicySimulator.Tests/UniGetUI.PolicySimulator.Tests.csproj b/policies/csharp/UniGetUI.PolicySimulator.Tests/UniGetUI.PolicySimulator.Tests.csproj new file mode 100644 index 0000000000..189519498c --- /dev/null +++ b/policies/csharp/UniGetUI.PolicySimulator.Tests/UniGetUI.PolicySimulator.Tests.csproj @@ -0,0 +1,12 @@ + + + Exe + net10.0 + enable + enable + latest + + + + + \ No newline at end of file diff --git a/policies/csharp/UniGetUI.PolicySimulator.slnx b/policies/csharp/UniGetUI.PolicySimulator.slnx new file mode 100644 index 0000000000..6db7933563 --- /dev/null +++ b/policies/csharp/UniGetUI.PolicySimulator.slnx @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/policies/named-pipe-http-wire-protocol.md b/policies/named-pipe-http-wire-protocol.md new file mode 100644 index 0000000000..df64a44222 --- /dev/null +++ b/policies/named-pipe-http-wire-protocol.md @@ -0,0 +1,240 @@ +# UniGetUI Package Broker HTTP Over Named Pipe Protocol + +This document defines a proposed local transport for package operation requests that are evaluated by the UniGetUI package policy engine. It keeps the package operation request body identical to `schemas/unigetui.package-request.schema.1.0.json` and defines how that document is carried over HTTP/1.1 on a Windows named pipe. + +The transport is intended for a future elevated broker. The current C# simulator remains unelevated and uses loopback HTTP, but it now mirrors the same versioned routes and response envelope. + +## Goals + +1. Use a local-only transport that is easier to ACL and audit than a TCP listener. +2. Keep the broker API request body as structured package operation data, not command text. +3. Let the broker authenticate the caller through named pipe security and compare it with request metadata. +4. Return a stable decision envelope for allow, deny, and validation-failure outcomes. +5. Preserve HTTP semantics so the client and broker can use ordinary request parsing, headers, status codes, and content negotiation. + +## Pipe Endpoint + +Production pipe name: + +```text +\\.\pipe\UniGetUI.PackageBroker.v1 +``` + +The `v1` suffix is part of the transport contract. A future breaking wire-protocol revision should use a new pipe name, such as `UniGetUI.PackageBroker.v2`, even if individual schema versions also change. + +The server creates a byte-stream named pipe. Clients write one complete HTTP/1.1 request and read one complete HTTP/1.1 response. Persistent connections are allowed but optional; clients must not pipeline requests. The broker may close idle connections after 30 seconds. + +## Security Profile + +The named pipe is a local security boundary, not a network boundary. + +The broker should: + +1. Create the pipe with an ACL owned by `SYSTEM` or `Administrators`. +2. Allow connection from interactive authenticated users that UniGetUI supports. +3. Use pipe impersonation to capture the caller SID, session id, integrity level, and authentication id. +4. Use `GetNamedPipeClientProcessId` or equivalent platform APIs to identify the client process. +5. Verify the client process path and signature if production policy requires only the official UniGetUI client. +6. Treat request `broker.effectiveUser` as a claim to verify, not as authority. A mismatch between the request body and the authenticated pipe token must fail closed. +7. Generate server-side audit ids and timestamps; clients must not choose those values. + +The broker must never execute a client-supplied command. The request schema intentionally has no command field. Allowed commands are built by the broker from validated request fields and UniGetUI manager helper semantics. + +## HTTP Profile + +The pipe carries HTTP/1.1 messages using ASCII headers, CRLF line endings, and UTF-8 bodies. + +Required request rules: + +| Rule | Requirement | +| --- | --- | +| Request target | Origin-form only, such as `/v1/package-operations/evaluate` | +| Host header | Required; use `Host: unigetui-broker` | +| Body framing | `Content-Length` is required for requests with a body | +| Chunking | `Transfer-Encoding: chunked` is not allowed | +| Compression | Request and response compression are not allowed | +| Trailers | HTTP trailers are not allowed | +| Character set | JSON request bodies are UTF-8 | +| Maximum header size | 32 KiB | +| Maximum request body size | 256 KiB | +| Maximum response body size | 1 MiB | + +The production wire format should use JSON. YAML remains an admin authoring format for policy files and a simulator convenience, but production clients should send canonical JSON. + +## Media Types + +Request media type: + +```text +application/vnd.unigetui.package-request+json; version=1.0 +``` + +Response media type: + +```text +application/vnd.unigetui.package-broker-response+json; version=1.0 +``` + +The broker may accept `application/json` as a compatibility alias during development, but production clients should send the vendor media type. + +## Common Headers + +Requests should include: + +| Header | Required | Description | +| --- | --- | --- | +| `UniGetUI-Protocol-Version` | Yes | Wire protocol version, `1.0` | +| `UniGetUI-Request-Id` | Yes | Must match body `requestId` | +| `Content-Type` | Yes | Request media type | +| `Accept` | Yes | Response media type | +| `Content-Length` | Yes | Exact body byte count | + +Responses include: + +| Header | Description | +| --- | --- | +| `UniGetUI-Protocol-Version` | Wire protocol version used by the broker | +| `UniGetUI-Audit-Id` | Server-generated audit id | +| `UniGetUI-Policy-Id` | Active policy id used for evaluation | +| `UniGetUI-Policy-Revision` | Active policy revision used for evaluation | +| `Content-Type` | Response media type | +| `Content-Length` | Exact body byte count | + +## Endpoints + +### `GET /v1/health` + +Returns readiness information. It does not expose policy rules. + +Successful response: `200 OK`. + +### `GET /v1/capabilities` + +Returns supported protocol versions, schema ids, managers, operations, and maximum payload sizes. Clients should call this after connecting if they need to adapt to broker capabilities. + +Successful response: `200 OK`. + +### `POST /v1/package-operations/evaluate` + +Validates one package operation request, evaluates it against the active policy, and returns the command the broker would execute when the decision is `allow`. This endpoint does not execute package managers. + +Request body schema: `schemas/unigetui.package-request.schema.1.0.json`. + +Response body schema: `schemas/unigetui.package-broker-response.schema.1.0.json`. + +### `POST /v1/package-operations` + +Reserved for the future production operation that evaluates policy and executes the package manager when allowed. The request and response envelopes should stay the same, but `execution.mode` should be `elevated` and the response should include execution result fields in a future response schema revision. + +## Status Codes + +| Status | Meaning | Response body | +| --- | --- | --- | +| `200 OK` | Request validated and policy allowed the operation | Broker response with `decision: allow` | +| `403 Forbidden` | Request validated and policy denied the operation | Broker response with `decision: deny` | +| `400 Bad Request` | HTTP message was incomplete or body was missing | Broker response with `decision: deny` when possible | +| `415 Unsupported Media Type` | `Content-Type` is not supported | Broker response with `decision: deny` when possible | +| `422 Unprocessable Content` | Body parsed but failed schema or semantic validation | Broker response with `decision: deny` and `ruleId: ` | +| `503 Service Unavailable` | Broker is starting, has no valid policy, or cannot evaluate | Broker response or `application/problem+json` | + +For policy denials and validation failures, the JSON body is more important than the HTTP status code. Clients should read `decision`, `ruleId`, `reason`, and `wouldExecute` from the response body. + +## Request Body + +The package operation request body is the same canonical document used by the policy simulator: + +```json +{ + "requestVersion": "1.0.0", + "requestType": "packageOperation", + "requestId": "req-winget-vscode-install", + "createdAt": "2026-05-05T12:00:00Z", + "operation": "install", + "manager": { "name": "Winget", "displayName": "WinGet", "executableFriendlyName": "winget.exe" }, + "source": { "name": "winget", "url": "https://cdn.winget.microsoft.com/cache", "isVirtualManager": false }, + "package": { "id": "Microsoft.VisualStudioCode", "name": "Microsoft Visual Studio Code" }, + "options": { + "scope": "machine", + "architecture": "x64", + "interactive": false, + "skipHashCheck": false, + "preRelease": false, + "customParameters": [], + "killBeforeOperation": [] + }, + "broker": { "requestedElevation": "elevated", "effectiveUser": "CONTOSO\\alice", "clientVersion": "3.2.0" } +} +``` + +The broker must check that `UniGetUI-Request-Id` matches `requestId`. The broker should reject duplicate request ids with conflicting bodies and may return a cached decision for exact duplicate bodies within a short replay window. + +## Response Body + +The response envelope always contains a policy decision. A denied response must have `wouldExecute: false` and an empty command array. + +```json +{ + "responseVersion": "1.0.0", + "responseType": "packageBrokerResponse", + "broker": { + "name": "UniGetUI Package Broker", + "protocolVersion": "1.0", + "transport": "http-named-pipe", + "pipeName": "\\\\.\\pipe\\UniGetUI.PackageBroker.v1", + "elevatedSimulation": false + }, + "auditId": "audit-20260505-000001", + "requestId": "req-winget-vscode-install", + "receivedAt": "2026-05-05T12:00:01Z", + "completedAt": "2026-05-05T12:00:01Z", + "manager": "Winget", + "source": "winget", + "packageId": "Microsoft.VisualStudioCode", + "operation": "install", + "decision": "allow", + "ruleId": "allow.winget.vscode", + "reason": "Visual Studio Code is approved for managed workstations.", + "wouldExecute": true, + "policy": { + "id": "contoso.desktop.standard-allowlist", + "revision": 4, + "policyVersion": "1.0.0" + }, + "execution": { + "mode": "elevated", + "command": ["winget.exe", "install", "--id", "Microsoft.VisualStudioCode", "--exact", "--source", "winget", "--scope", "machine", "--silent", "--architecture", "x64"], + "note": "Command was constructed by the broker from validated request fields." + } +} +``` + +## Versioning + +The wire protocol has three related version values: + +| Version | Location | Purpose | +| --- | --- | --- | +| Pipe version | Pipe name suffix, such as `.v1` | Breaking transport changes | +| Protocol version | `UniGetUI-Protocol-Version` header and `broker.protocolVersion` | HTTP route/header semantics | +| Document versions | Request, response, and policy schema versions | JSON body contracts | + +Compatible schema additions require a new schema id and version but do not necessarily require a new pipe name. Breaking route, framing, or authentication changes should use a new pipe name. + +## Named Pipe To HTTP Mapping + +A client writes the HTTP request exactly as it would over a TCP stream, except the stream is a named pipe handle. The broker reads until headers are complete, reads exactly `Content-Length` body bytes, evaluates the request, writes an HTTP response, and either waits for the next request on the same pipe or closes the pipe. + +The response should be generated from server-observed state wherever possible: + +| Response field | Source of truth | +| --- | --- | +| `auditId` | Broker-generated | +| `receivedAt`, `completedAt` | Broker-generated | +| `policy` | Active validated policy | +| `decision`, `ruleId`, `reason` | Policy evaluator | +| `execution.command` | Broker command builder | +| `broker.pipeName`, client identity | Named pipe server APIs | + +## Compatibility Alias In The Simulator + +The C# simulator also accepts `POST /requests` over loopback HTTP as a compatibility alias for earlier samples. New clients should use `POST /v1/package-operations/evaluate`. \ No newline at end of file diff --git a/policies/samples/corporate-allowlist.policy.json b/policies/samples/corporate-allowlist.policy.json new file mode 100644 index 0000000000..b18991b7d8 --- /dev/null +++ b/policies/samples/corporate-allowlist.policy.json @@ -0,0 +1,134 @@ +{ + "$schema": "https://aka.ms/unigetui/package-policy.schema.1.0.json", + "policyVersion": "1.0.0", + "policyType": "packageBrokerPolicy", + "metadata": { + "id": "contoso.desktop.standard-allowlist", + "publisher": "Contoso IT", + "revision": 4, + "publishedAt": "2026-05-05T00:00:00Z", + "description": "Fail-closed policy for standard workstation package installs." + }, + "enforcement": { + "defaultDecision": "deny", + "rulePrecedence": "priorityThenDeny" + }, + "rules": [ + { + "id": "deny.integrity-bypass", + "enabled": true, + "priority": 10, + "decision": "deny", + "reason": "Integrity and publisher checks cannot be bypassed by brokered requests.", + "match": { + "operations": [ + "install", + "update" + ], + "skipHashCheck": [ + true + ] + } + }, + { + "id": "deny.custom-parameters", + "enabled": true, + "priority": 20, + "decision": "deny", + "reason": "Custom package-manager parameters are not allowed in the workstation allow list.", + "match": { + "hasCustomParameters": [ + true + ] + } + }, + { + "id": "deny.prepost-commands", + "enabled": true, + "priority": 30, + "decision": "deny", + "reason": "Pre and post operation commands are not allowed in the workstation allow list.", + "match": { + "hasPrePostCommands": [ + true + ] + } + }, + { + "id": "allow.winget.vscode", + "enabled": true, + "priority": 100, + "decision": "allow", + "reason": "Visual Studio Code is approved for managed workstations.", + "match": { + "operations": [ + "install", + "update" + ], + "managers": [ + "Winget" + ], + "sources": [ + "winget" + ], + "packageIdentifiers": [ + "Microsoft.VisualStudioCode" + ], + "scopes": [ + "user", + "machine" + ], + "architectures": [ + "x64", + "arm64" + ] + }, + "constraints": { + "allowInteractive": false, + "allowSkipHashCheck": false, + "allowPreRelease": false, + "allowCustomParameters": false, + "allowPrePostCommands": false, + "allowKillBeforeOperation": false + } + }, + { + "id": "allow.winget.powertoys", + "enabled": true, + "priority": 100, + "decision": "allow", + "reason": "PowerToys is approved for developer workstations.", + "match": { + "operations": [ + "install", + "update" + ], + "managers": [ + "Winget" + ], + "sources": [ + "winget" + ], + "packageIdentifiers": [ + "Microsoft.PowerToys" + ], + "scopes": [ + "user", + "machine" + ], + "architectures": [ + "x64", + "arm64" + ] + }, + "constraints": { + "allowInteractive": false, + "allowSkipHashCheck": false, + "allowPreRelease": false, + "allowCustomParameters": false, + "allowPrePostCommands": false, + "allowKillBeforeOperation": false + } + } + ] +} \ No newline at end of file diff --git a/policies/samples/corporate-allowlist.policy.yaml b/policies/samples/corporate-allowlist.policy.yaml new file mode 100644 index 0000000000..34b3fd741d --- /dev/null +++ b/policies/samples/corporate-allowlist.policy.yaml @@ -0,0 +1,52 @@ +"$schema": https://aka.ms/unigetui/package-policy.schema.1.0.json +policyVersion: 1.0.0 +policyType: packageBrokerPolicy +metadata: + id: contoso.desktop.standard-allowlist-yaml + publisher: Contoso IT + revision: 1 + publishedAt: "2026-05-05T00:00:00Z" + description: Fail-closed YAML policy for standard workstation package installs. +enforcement: + defaultDecision: deny + rulePrecedence: priorityThenDeny +rules: + - id: deny.integrity-bypass + enabled: true + priority: 10 + decision: deny + reason: Integrity and publisher checks cannot be bypassed by brokered requests. + match: + operations: + - install + - update + skipHashCheck: + - true + - id: allow.winget.vscode + enabled: true + priority: 100 + decision: allow + reason: Visual Studio Code is approved for managed workstations. + match: + operations: + - install + - update + managers: + - Winget + sources: + - winget + packageIdentifiers: + - Microsoft.VisualStudioCode + scopes: + - user + - machine + architectures: + - x64 + - arm64 + constraints: + allowInteractive: false + allowSkipHashCheck: false + allowPreRelease: false + allowCustomParameters: false + allowPrePostCommands: false + allowKillBeforeOperation: false \ No newline at end of file diff --git a/policies/samples/deny-risky-options.policy.json b/policies/samples/deny-risky-options.policy.json new file mode 100644 index 0000000000..a30fa5574d --- /dev/null +++ b/policies/samples/deny-risky-options.policy.json @@ -0,0 +1,81 @@ +{ + "$schema": "https://aka.ms/unigetui/package-policy.schema.1.0.json", + "policyVersion": "1.0.0", + "policyType": "packageBrokerPolicy", + "metadata": { + "id": "contoso.desktop.deny-risky-options", + "publisher": "Contoso IT", + "revision": 2, + "publishedAt": "2026-05-05T00:00:00Z", + "description": "Default-allow policy that blocks risky broker request options." + }, + "enforcement": { + "defaultDecision": "allow", + "rulePrecedence": "priorityThenDeny" + }, + "rules": [ + { + "id": "deny.integrity-bypass", + "priority": 10, + "decision": "deny", + "reason": "Do not broker installs that skip WinGet hash checks or PowerShell publisher checks.", + "match": { + "operations": [ + "install", + "update" + ], + "skipHashCheck": [ + true + ] + } + }, + { + "id": "deny.manager-custom-parameters", + "priority": 20, + "decision": "deny", + "reason": "Custom package-manager parameters require a dedicated exception policy.", + "match": { + "hasCustomParameters": [ + true + ] + } + }, + { + "id": "deny.prepost-commands", + "priority": 30, + "decision": "deny", + "reason": "Pre and post operation commands are outside the package manager trust boundary.", + "match": { + "hasPrePostCommands": [ + true + ] + } + }, + { + "id": "deny.kill-process-actions", + "priority": 40, + "decision": "deny", + "reason": "Killing processes before a brokered package operation is not allowed by this policy.", + "match": { + "hasKillBeforeOperation": [ + true + ] + } + }, + { + "id": "deny.unapproved-winget-source", + "priority": 50, + "decision": "deny", + "reason": "Only the default WinGet source is accepted by this deny-list sample.", + "match": { + "managers": [ + "Winget" + ], + "sources": [ + "msstore", + "winget-fonts" + ] + } + } + ] +} \ No newline at end of file diff --git a/policies/samples/invalid/policies/invalid-failure-decision.policy.json b/policies/samples/invalid/policies/invalid-failure-decision.policy.json new file mode 100644 index 0000000000..a63e37c619 --- /dev/null +++ b/policies/samples/invalid/policies/invalid-failure-decision.policy.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://aka.ms/unigetui/package-policy.schema.1.0.json", + "policyVersion": "1.0.0", + "policyType": "packageBrokerPolicy", + "metadata": { + "id": "contoso.invalid.failure-decision", + "publisher": "Contoso IT", + "revision": 1, + "publishedAt": "2026-05-05T00:00:00Z" + }, + "enforcement": { + "defaultDecision": "allow", + "failureDecision": "allow", + "rulePrecedence": "priorityThenDeny" + }, + "rules": [ + { + "id": "allow.everything", + "priority": 100, + "decision": "allow", + "match": { + "packageIdentifiers": [ + "*" + ] + } + } + ] +} \ No newline at end of file diff --git a/policies/samples/invalid/requests/missing-package-id.request.json b/policies/samples/invalid/requests/missing-package-id.request.json new file mode 100644 index 0000000000..cc5993e245 --- /dev/null +++ b/policies/samples/invalid/requests/missing-package-id.request.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://aka.ms/unigetui/package-request.schema.1.0.json", + "requestVersion": "1.0.0", + "requestType": "packageOperation", + "requestId": "req-invalid-missing-package-id", + "createdAt": "2026-05-05T12:23:00Z", + "operation": "install", + "manager": { + "name": "Winget", + "displayName": "WinGet", + "executableFriendlyName": "winget.exe" + }, + "source": { + "name": "winget", + "url": "https://cdn.winget.microsoft.com/cache", + "isVirtualManager": false + }, + "package": { + "name": "Microsoft Visual Studio Code", + "architecture": "x64" + }, + "options": { + "scope": "machine", + "interactive": false, + "skipHashCheck": false, + "preRelease": false, + "customParameters": [], + "killBeforeOperation": [] + }, + "broker": { + "requestedElevation": "elevated", + "effectiveUser": "CONTOSO\\alice", + "clientVersion": "3.2.0" + } +} \ No newline at end of file diff --git a/policies/samples/powershell-advanced.policy.json b/policies/samples/powershell-advanced.policy.json new file mode 100644 index 0000000000..479a571078 --- /dev/null +++ b/policies/samples/powershell-advanced.policy.json @@ -0,0 +1,82 @@ +{ + "$schema": "https://aka.ms/unigetui/package-policy.schema.1.0.json", + "policyVersion": "1.0.0", + "policyType": "packageBrokerPolicy", + "metadata": { + "id": "contoso.powershell.advanced-scenarios", + "publisher": "Contoso IT", + "revision": 1, + "publishedAt": "2026-05-05T00:00:00Z", + "description": "PowerShell policy fixture for source, version range, and update operation coverage." + }, + "enforcement": { + "defaultDecision": "deny", + "rulePrecedence": "priorityThenDeny" + }, + "rules": [ + { + "id": "deny.powershell.untrusted-source", + "priority": 5, + "decision": "deny", + "reason": "Only PSGallery is approved for brokered PowerShell module operations.", + "match": { + "managers": [ + "PowerShell" + ], + "sources": [ + "PoshTestGallery" + ] + } + }, + { + "id": "deny.powershell.prerelease", + "priority": 10, + "decision": "deny", + "reason": "Prerelease PowerShell modules are not approved in advanced scenarios.", + "match": { + "managers": [ + "PowerShell" + ], + "preRelease": [ + true + ] + } + }, + { + "id": "allow.powershell.pester.versioned", + "priority": 100, + "decision": "allow", + "reason": "Pester is approved from PSGallery for CurrentUser install and update operations within the supported version range.", + "match": { + "operations": [ + "install", + "update" + ], + "managers": [ + "PowerShell" + ], + "sources": [ + "PSGallery" + ], + "packageIdentifiers": [ + "Pester" + ], + "versionRange": { + "minVersion": "5.0.0", + "maxVersion": "6.0.0", + "includePrerelease": false + }, + "scopes": [ + "user" + ] + }, + "constraints": { + "allowSkipHashCheck": false, + "allowPreRelease": false, + "allowCustomParameters": false, + "allowPrePostCommands": false, + "allowKillBeforeOperation": false + } + } + ] +} \ No newline at end of file diff --git a/policies/samples/powershell-current-user.policy.json b/policies/samples/powershell-current-user.policy.json new file mode 100644 index 0000000000..255d0bae66 --- /dev/null +++ b/policies/samples/powershell-current-user.policy.json @@ -0,0 +1,91 @@ +{ + "$schema": "https://aka.ms/unigetui/package-policy.schema.1.0.json", + "policyVersion": "1.0.0", + "policyType": "packageBrokerPolicy", + "metadata": { + "id": "contoso.powershell.current-user-modules", + "publisher": "Contoso IT", + "revision": 3, + "publishedAt": "2026-05-05T00:00:00Z", + "description": "PowerShell Gallery module policy for non-admin CurrentUser installs." + }, + "enforcement": { + "defaultDecision": "deny", + "rulePrecedence": "priorityThenDeny" + }, + "rules": [ + { + "id": "deny.powershell.machine-scope", + "priority": 10, + "decision": "deny", + "reason": "PowerShell module installs through the broker must use CurrentUser scope.", + "match": { + "managers": [ + "PowerShell" + ], + "scopes": [ + "machine" + ] + } + }, + { + "id": "deny.powershell.elevated", + "priority": 20, + "decision": "deny", + "reason": "PowerShell module installs must not request an elevated broker context.", + "match": { + "managers": [ + "PowerShell" + ], + "elevation": [ + "elevated" + ] + } + }, + { + "id": "deny.powershell.prerelease", + "priority": 30, + "decision": "deny", + "reason": "Prerelease PowerShell modules are not approved.", + "match": { + "managers": [ + "PowerShell" + ], + "preRelease": [ + true + ] + } + }, + { + "id": "allow.powershell.pester", + "priority": 100, + "decision": "allow", + "reason": "Pester is approved from PSGallery for CurrentUser installs.", + "match": { + "operations": [ + "install", + "update" + ], + "managers": [ + "PowerShell" + ], + "sources": [ + "PSGallery" + ], + "packageIdentifiers": [ + "Pester" + ], + "scopes": [ + "user" + ] + }, + "constraints": { + "allowSkipHashCheck": false, + "allowPreRelease": false, + "allowCustomParameters": false, + "allowPrePostCommands": false, + "allowKillBeforeOperation": false + } + } + ] +} \ No newline at end of file diff --git a/policies/samples/requests/powershell-pester-allusers.request.json b/policies/samples/requests/powershell-pester-allusers.request.json new file mode 100644 index 0000000000..878bd10b3e --- /dev/null +++ b/policies/samples/requests/powershell-pester-allusers.request.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://aka.ms/unigetui/package-request.schema.1.0.json", + "requestVersion": "1.0.0", + "requestType": "packageOperation", + "requestId": "req-powershell-pester-allusers", + "createdAt": "2026-05-05T12:25:00Z", + "operation": "install", + "manager": { + "name": "PowerShell", + "displayName": "PowerShell 5.x", + "executableFriendlyName": "powershell.exe" + }, + "source": { + "name": "PSGallery", + "url": "https://www.powershellgallery.com/api/v2", + "isVirtualManager": false + }, + "package": { + "id": "Pester", + "name": "Pester" + }, + "options": { + "scope": "machine", + "interactive": false, + "runAsAdministrator": true, + "skipHashCheck": false, + "preRelease": false, + "customParameters": [], + "killBeforeOperation": [] + }, + "broker": { + "requestedElevation": "elevated", + "effectiveUser": "CONTOSO\\alice", + "clientVersion": "3.2.0" + } +} \ No newline at end of file diff --git a/policies/samples/requests/powershell-pester-currentuser.request.json b/policies/samples/requests/powershell-pester-currentuser.request.json new file mode 100644 index 0000000000..264939f8f2 --- /dev/null +++ b/policies/samples/requests/powershell-pester-currentuser.request.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://aka.ms/unigetui/package-request.schema.1.0.json", + "requestVersion": "1.0.0", + "requestType": "packageOperation", + "requestId": "req-powershell-pester-currentuser", + "createdAt": "2026-05-05T12:20:00Z", + "operation": "install", + "manager": { + "name": "PowerShell", + "displayName": "PowerShell 5.x", + "executableFriendlyName": "powershell.exe" + }, + "source": { + "name": "PSGallery", + "url": "https://www.powershellgallery.com/api/v2", + "isVirtualManager": false + }, + "package": { + "id": "Pester", + "name": "Pester" + }, + "options": { + "scope": "user", + "interactive": false, + "runAsAdministrator": false, + "skipHashCheck": false, + "preRelease": false, + "customParameters": [], + "killBeforeOperation": [] + }, + "broker": { + "requestedElevation": "standard", + "effectiveUser": "CONTOSO\\alice", + "clientVersion": "3.2.0" + } +} \ No newline at end of file diff --git a/policies/samples/requests/powershell-pester-poshtestgallery.request.json b/policies/samples/requests/powershell-pester-poshtestgallery.request.json new file mode 100644 index 0000000000..3db4b013a5 --- /dev/null +++ b/policies/samples/requests/powershell-pester-poshtestgallery.request.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://aka.ms/unigetui/package-request.schema.1.0.json", + "requestVersion": "1.0.0", + "requestType": "packageOperation", + "requestId": "req-powershell-pester-poshtestgallery", + "createdAt": "2026-05-05T12:21:00Z", + "operation": "install", + "manager": { + "name": "PowerShell", + "displayName": "PowerShell", + "executableFriendlyName": "pwsh.exe" + }, + "source": { + "name": "PoshTestGallery", + "url": "https://www.poshtestgallery.com/api/v2", + "isVirtualManager": false + }, + "package": { + "id": "Pester", + "name": "Pester" + }, + "options": { + "scope": "user", + "interactive": false, + "runAsAdministrator": false, + "skipHashCheck": false, + "preRelease": false, + "customParameters": [], + "killBeforeOperation": [] + }, + "broker": { + "requestedElevation": "standard", + "effectiveUser": "CONTOSO\\alice", + "clientVersion": "3.2.0" + } +} \ No newline at end of file diff --git a/policies/samples/requests/powershell-pester-prerelease.request.json b/policies/samples/requests/powershell-pester-prerelease.request.json new file mode 100644 index 0000000000..9b81f8f74b --- /dev/null +++ b/policies/samples/requests/powershell-pester-prerelease.request.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://aka.ms/unigetui/package-request.schema.1.0.json", + "requestVersion": "1.0.0", + "requestType": "packageOperation", + "requestId": "req-powershell-pester-prerelease", + "createdAt": "2026-05-05T12:21:00Z", + "operation": "install", + "manager": { + "name": "PowerShell", + "displayName": "PowerShell", + "executableFriendlyName": "PowerShellGet" + }, + "source": { + "name": "PSGallery", + "url": "https://www.powershellgallery.com/api/v2", + "isVirtualManager": false + }, + "package": { + "id": "Pester", + "name": "Pester" + }, + "options": { + "scope": "user", + "interactive": false, + "runAsAdministrator": false, + "skipHashCheck": false, + "preRelease": true, + "customParameters": [], + "killBeforeOperation": [] + }, + "broker": { + "requestedElevation": "standard", + "effectiveUser": "CONTOSO\\alice", + "clientVersion": "3.2.0" + } +} \ No newline at end of file diff --git a/policies/samples/requests/powershell-pester-skipcheck.request.json b/policies/samples/requests/powershell-pester-skipcheck.request.json new file mode 100644 index 0000000000..4bc5d6d444 --- /dev/null +++ b/policies/samples/requests/powershell-pester-skipcheck.request.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://aka.ms/unigetui/package-request.schema.1.0.json", + "requestVersion": "1.0.0", + "requestType": "packageOperation", + "requestId": "req-powershell-pester-skipcheck", + "createdAt": "2026-05-05T12:22:00Z", + "operation": "install", + "manager": { + "name": "PowerShell", + "displayName": "PowerShell", + "executableFriendlyName": "PowerShellGet" + }, + "source": { + "name": "PSGallery", + "url": "https://www.powershellgallery.com/api/v2", + "isVirtualManager": false + }, + "package": { + "id": "Pester", + "name": "Pester" + }, + "options": { + "scope": "user", + "interactive": false, + "runAsAdministrator": false, + "skipHashCheck": true, + "preRelease": false, + "customParameters": [], + "killBeforeOperation": [] + }, + "broker": { + "requestedElevation": "standard", + "effectiveUser": "CONTOSO\\alice", + "clientVersion": "3.2.0" + } +} \ No newline at end of file diff --git a/policies/samples/requests/powershell-pester-update-currentuser.request.json b/policies/samples/requests/powershell-pester-update-currentuser.request.json new file mode 100644 index 0000000000..2d672bb993 --- /dev/null +++ b/policies/samples/requests/powershell-pester-update-currentuser.request.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://aka.ms/unigetui/package-request.schema.1.0.json", + "requestVersion": "1.0.0", + "requestType": "packageOperation", + "requestId": "req-powershell-pester-update-currentuser", + "createdAt": "2026-05-05T12:24:00Z", + "operation": "update", + "manager": { + "name": "PowerShell", + "displayName": "PowerShell", + "executableFriendlyName": "pwsh.exe" + }, + "source": { + "name": "PSGallery", + "url": "https://www.powershellgallery.com/api/v2", + "isVirtualManager": false + }, + "package": { + "id": "Pester", + "name": "Pester", + "version": "5.5.0" + }, + "options": { + "scope": "user", + "interactive": false, + "runAsAdministrator": false, + "skipHashCheck": false, + "preRelease": false, + "customParameters": [], + "killBeforeOperation": [] + }, + "broker": { + "requestedElevation": "standard", + "effectiveUser": "CONTOSO\\alice", + "clientVersion": "3.2.0" + } +} \ No newline at end of file diff --git a/policies/samples/requests/powershell-pester-version-allowed.request.json b/policies/samples/requests/powershell-pester-version-allowed.request.json new file mode 100644 index 0000000000..31f4fb55b7 --- /dev/null +++ b/policies/samples/requests/powershell-pester-version-allowed.request.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://aka.ms/unigetui/package-request.schema.1.0.json", + "requestVersion": "1.0.0", + "requestType": "packageOperation", + "requestId": "req-powershell-pester-version-allowed", + "createdAt": "2026-05-05T12:22:00Z", + "operation": "install", + "manager": { + "name": "PowerShell", + "displayName": "PowerShell", + "executableFriendlyName": "pwsh.exe" + }, + "source": { + "name": "PSGallery", + "url": "https://www.powershellgallery.com/api/v2", + "isVirtualManager": false + }, + "package": { + "id": "Pester", + "name": "Pester", + "version": "5.5.0" + }, + "options": { + "scope": "user", + "interactive": false, + "runAsAdministrator": false, + "skipHashCheck": false, + "preRelease": false, + "customParameters": [], + "killBeforeOperation": [] + }, + "broker": { + "requestedElevation": "standard", + "effectiveUser": "CONTOSO\\alice", + "clientVersion": "3.2.0" + } +} \ No newline at end of file diff --git a/policies/samples/requests/powershell-pester-version-out-of-range.request.json b/policies/samples/requests/powershell-pester-version-out-of-range.request.json new file mode 100644 index 0000000000..d3328ec4a5 --- /dev/null +++ b/policies/samples/requests/powershell-pester-version-out-of-range.request.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://aka.ms/unigetui/package-request.schema.1.0.json", + "requestVersion": "1.0.0", + "requestType": "packageOperation", + "requestId": "req-powershell-pester-version-out-of-range", + "createdAt": "2026-05-05T12:23:00Z", + "operation": "install", + "manager": { + "name": "PowerShell", + "displayName": "PowerShell", + "executableFriendlyName": "pwsh.exe" + }, + "source": { + "name": "PSGallery", + "url": "https://www.powershellgallery.com/api/v2", + "isVirtualManager": false + }, + "package": { + "id": "Pester", + "name": "Pester", + "version": "6.1.0" + }, + "options": { + "scope": "user", + "interactive": false, + "runAsAdministrator": false, + "skipHashCheck": false, + "preRelease": false, + "customParameters": [], + "killBeforeOperation": [] + }, + "broker": { + "requestedElevation": "standard", + "effectiveUser": "CONTOSO\\alice", + "clientVersion": "3.2.0" + } +} \ No newline at end of file diff --git a/policies/samples/requests/status-query-minimal.request.json b/policies/samples/requests/status-query-minimal.request.json new file mode 100644 index 0000000000..26b75df4f1 --- /dev/null +++ b/policies/samples/requests/status-query-minimal.request.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://aka.ms/unigetui/package-operation-status-request.schema.1.0.json", + "requestVersion": "1.0.0", + "requestType": "packageOperationStatus", + "requestId": "req-abc-123", + "broker": { + "requestedElevation": "standard", + "effectiveUser": "WORKSTATION\\bob" + } +} diff --git a/policies/samples/requests/status-query-running.request.json b/policies/samples/requests/status-query-running.request.json new file mode 100644 index 0000000000..f6cccfcc06 --- /dev/null +++ b/policies/samples/requests/status-query-running.request.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://aka.ms/unigetui/package-operation-status-request.schema.1.0.json", + "requestVersion": "1.0.0", + "requestType": "packageOperationStatus", + "requestId": "req-winget-vscode-install", + "broker": { + "requestedElevation": "elevated", + "effectiveUser": "CONTOSO\\alice", + "clientVersion": "3.2.0" + } +} diff --git a/policies/samples/requests/winget-git-custom-location-allowed.request.json b/policies/samples/requests/winget-git-custom-location-allowed.request.json new file mode 100644 index 0000000000..8de3b0b082 --- /dev/null +++ b/policies/samples/requests/winget-git-custom-location-allowed.request.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://aka.ms/unigetui/package-request.schema.1.0.json", + "requestVersion": "1.0.0", + "requestType": "packageOperation", + "requestId": "req-winget-git-custom-location-allowed", + "createdAt": "2026-05-05T12:15:00Z", + "operation": "install", + "manager": { + "name": "Winget", + "displayName": "WinGet", + "executableFriendlyName": "winget.exe" + }, + "source": { + "name": "winget", + "url": "https://cdn.winget.microsoft.com/cache", + "isVirtualManager": false + }, + "package": { + "id": "Git.Git", + "name": "Git", + "architecture": "x64" + }, + "options": { + "scope": "machine", + "interactive": false, + "runAsAdministrator": true, + "skipHashCheck": false, + "preRelease": false, + "customInstallLocation": "C:\\Tools\\Git", + "customParameters": [], + "killBeforeOperation": [] + }, + "broker": { + "requestedElevation": "elevated", + "effectiveUser": "CONTOSO\\alice", + "clientVersion": "3.2.0" + } +} \ No newline at end of file diff --git a/policies/samples/requests/winget-git-custom-location-denied.request.json b/policies/samples/requests/winget-git-custom-location-denied.request.json new file mode 100644 index 0000000000..a4cf180101 --- /dev/null +++ b/policies/samples/requests/winget-git-custom-location-denied.request.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://aka.ms/unigetui/package-request.schema.1.0.json", + "requestVersion": "1.0.0", + "requestType": "packageOperation", + "requestId": "req-winget-git-custom-location-denied", + "createdAt": "2026-05-05T12:16:00Z", + "operation": "install", + "manager": { + "name": "Winget", + "displayName": "WinGet", + "executableFriendlyName": "winget.exe" + }, + "source": { + "name": "winget", + "url": "https://cdn.winget.microsoft.com/cache", + "isVirtualManager": false + }, + "package": { + "id": "Git.Git", + "name": "Git", + "architecture": "x64" + }, + "options": { + "scope": "machine", + "interactive": false, + "runAsAdministrator": true, + "skipHashCheck": false, + "preRelease": false, + "customInstallLocation": "C:\\Temp\\Git", + "customParameters": [], + "killBeforeOperation": [] + }, + "broker": { + "requestedElevation": "elevated", + "effectiveUser": "CONTOSO\\alice", + "clientVersion": "3.2.0" + } +} \ No newline at end of file diff --git a/policies/samples/requests/winget-git-custom-param-allowed.request.json b/policies/samples/requests/winget-git-custom-param-allowed.request.json new file mode 100644 index 0000000000..3efa241988 --- /dev/null +++ b/policies/samples/requests/winget-git-custom-param-allowed.request.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://aka.ms/unigetui/package-request.schema.1.0.json", + "requestVersion": "1.0.0", + "requestType": "packageOperation", + "requestId": "req-winget-git-custom-param-allowed", + "createdAt": "2026-05-05T12:17:00Z", + "operation": "install", + "manager": { + "name": "Winget", + "displayName": "WinGet", + "executableFriendlyName": "winget.exe" + }, + "source": { + "name": "winget", + "url": "https://cdn.winget.microsoft.com/cache", + "isVirtualManager": false + }, + "package": { + "id": "Git.Git", + "name": "Git", + "architecture": "x64" + }, + "options": { + "scope": "machine", + "interactive": false, + "runAsAdministrator": true, + "skipHashCheck": false, + "preRelease": false, + "customParameters": [ + "--accept-source-agreements" + ], + "killBeforeOperation": [] + }, + "broker": { + "requestedElevation": "elevated", + "effectiveUser": "CONTOSO\\alice", + "clientVersion": "3.2.0" + } +} \ No newline at end of file diff --git a/policies/samples/requests/winget-git-custom-param-denied.request.json b/policies/samples/requests/winget-git-custom-param-denied.request.json new file mode 100644 index 0000000000..54e7f56123 --- /dev/null +++ b/policies/samples/requests/winget-git-custom-param-denied.request.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://aka.ms/unigetui/package-request.schema.1.0.json", + "requestVersion": "1.0.0", + "requestType": "packageOperation", + "requestId": "req-winget-git-custom-param-denied", + "createdAt": "2026-05-05T12:18:00Z", + "operation": "install", + "manager": { + "name": "Winget", + "displayName": "WinGet", + "executableFriendlyName": "winget.exe" + }, + "source": { + "name": "winget", + "url": "https://cdn.winget.microsoft.com/cache", + "isVirtualManager": false + }, + "package": { + "id": "Git.Git", + "name": "Git", + "architecture": "x64" + }, + "options": { + "scope": "machine", + "interactive": false, + "runAsAdministrator": true, + "skipHashCheck": false, + "preRelease": false, + "customParameters": [ + "--override" + ], + "killBeforeOperation": [] + }, + "broker": { + "requestedElevation": "elevated", + "effectiveUser": "CONTOSO\\alice", + "clientVersion": "3.2.0" + } +} \ No newline at end of file diff --git a/policies/samples/requests/winget-git-uninstall.request.json b/policies/samples/requests/winget-git-uninstall.request.json new file mode 100644 index 0000000000..eae844b46c --- /dev/null +++ b/policies/samples/requests/winget-git-uninstall.request.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://aka.ms/unigetui/package-request.schema.1.0.json", + "requestVersion": "1.0.0", + "requestType": "packageOperation", + "requestId": "req-winget-git-uninstall", + "createdAt": "2026-05-05T12:13:00Z", + "operation": "uninstall", + "manager": { + "name": "Winget", + "displayName": "WinGet", + "executableFriendlyName": "winget.exe" + }, + "source": { + "name": "winget", + "url": "https://cdn.winget.microsoft.com/cache", + "isVirtualManager": false + }, + "package": { + "id": "Git.Git", + "name": "Git", + "architecture": "x64" + }, + "options": { + "scope": "machine", + "interactive": false, + "runAsAdministrator": true, + "skipHashCheck": false, + "preRelease": false, + "customParameters": [], + "killBeforeOperation": [] + }, + "broker": { + "requestedElevation": "elevated", + "effectiveUser": "CONTOSO\\alice", + "clientVersion": "3.2.0" + } +} \ No newline at end of file diff --git a/policies/samples/requests/winget-powertoys-install.request.json b/policies/samples/requests/winget-powertoys-install.request.json new file mode 100644 index 0000000000..294a0ebd8c --- /dev/null +++ b/policies/samples/requests/winget-powertoys-install.request.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://aka.ms/unigetui/package-request.schema.1.0.json", + "requestVersion": "1.0.0", + "requestType": "packageOperation", + "requestId": "req-winget-powertoys-install", + "createdAt": "2026-05-05T12:10:00Z", + "operation": "install", + "manager": { + "name": "Winget", + "displayName": "WinGet", + "executableFriendlyName": "winget.exe" + }, + "source": { + "name": "winget", + "url": "https://cdn.winget.microsoft.com/cache", + "isVirtualManager": false + }, + "package": { + "id": "Microsoft.PowerToys", + "name": "Microsoft PowerToys", + "architecture": "x64" + }, + "options": { + "scope": "machine", + "interactive": false, + "runAsAdministrator": true, + "skipHashCheck": false, + "preRelease": false, + "customParameters": [], + "killBeforeOperation": [] + }, + "broker": { + "requestedElevation": "elevated", + "effectiveUser": "CONTOSO\\alice", + "clientVersion": "3.2.0" + } +} \ No newline at end of file diff --git a/policies/samples/requests/winget-unknown-install.request.json b/policies/samples/requests/winget-unknown-install.request.json new file mode 100644 index 0000000000..7e3dc86a94 --- /dev/null +++ b/policies/samples/requests/winget-unknown-install.request.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://aka.ms/unigetui/package-request.schema.1.0.json", + "requestVersion": "1.0.0", + "requestType": "packageOperation", + "requestId": "req-winget-unknown-install", + "createdAt": "2026-05-05T12:05:00Z", + "operation": "install", + "manager": { + "name": "Winget", + "displayName": "WinGet", + "executableFriendlyName": "winget.exe" + }, + "source": { + "name": "winget", + "url": "https://cdn.winget.microsoft.com/cache", + "isVirtualManager": false + }, + "package": { + "id": "Example.UnapprovedTool", + "name": "Unapproved Tool", + "architecture": "x64" + }, + "options": { + "scope": "user", + "interactive": false, + "runAsAdministrator": false, + "skipHashCheck": false, + "preRelease": false, + "customParameters": [], + "killBeforeOperation": [] + }, + "broker": { + "requestedElevation": "standard", + "effectiveUser": "CONTOSO\\alice", + "clientVersion": "3.2.0" + } +} \ No newline at end of file diff --git a/policies/samples/requests/winget-vscode-custom-param.request.json b/policies/samples/requests/winget-vscode-custom-param.request.json new file mode 100644 index 0000000000..a56d7e2de8 --- /dev/null +++ b/policies/samples/requests/winget-vscode-custom-param.request.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://aka.ms/unigetui/package-request.schema.1.0.json", + "requestVersion": "1.0.0", + "requestType": "packageOperation", + "requestId": "req-winget-vscode-custom-param", + "createdAt": "2026-05-05T12:15:00Z", + "operation": "install", + "manager": { + "name": "Winget", + "displayName": "WinGet", + "executableFriendlyName": "winget.exe" + }, + "source": { + "name": "winget", + "url": "https://cdn.winget.microsoft.com/cache", + "isVirtualManager": false + }, + "package": { + "id": "Microsoft.VisualStudioCode", + "name": "Microsoft Visual Studio Code", + "architecture": "x64" + }, + "options": { + "scope": "machine", + "interactive": false, + "runAsAdministrator": true, + "skipHashCheck": false, + "preRelease": false, + "customParameters": [ + "--override", + "/VERYSILENT /NORESTART" + ], + "killBeforeOperation": [] + }, + "broker": { + "requestedElevation": "elevated", + "effectiveUser": "CONTOSO\\alice", + "clientVersion": "3.2.0" + } +} \ No newline at end of file diff --git a/policies/samples/requests/winget-vscode-install.request.json b/policies/samples/requests/winget-vscode-install.request.json new file mode 100644 index 0000000000..c3eb8e8dc5 --- /dev/null +++ b/policies/samples/requests/winget-vscode-install.request.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://aka.ms/unigetui/package-request.schema.1.0.json", + "requestVersion": "1.0.0", + "requestType": "packageOperation", + "requestId": "req-winget-vscode-install", + "createdAt": "2026-05-05T12:00:00Z", + "operation": "install", + "manager": { + "name": "Winget", + "displayName": "WinGet", + "executableFriendlyName": "winget.exe" + }, + "source": { + "name": "winget", + "url": "https://cdn.winget.microsoft.com/cache", + "isVirtualManager": false + }, + "package": { + "id": "Microsoft.VisualStudioCode", + "name": "Microsoft Visual Studio Code", + "architecture": "x64" + }, + "options": { + "scope": "machine", + "interactive": false, + "runAsAdministrator": true, + "skipHashCheck": false, + "preRelease": false, + "customParameters": [], + "killBeforeOperation": [] + }, + "broker": { + "requestedElevation": "elevated", + "effectiveUser": "CONTOSO\\alice", + "clientVersion": "3.2.0" + } +} \ No newline at end of file diff --git a/policies/samples/requests/winget-vscode-install.request.yaml b/policies/samples/requests/winget-vscode-install.request.yaml new file mode 100644 index 0000000000..696d4c58d7 --- /dev/null +++ b/policies/samples/requests/winget-vscode-install.request.yaml @@ -0,0 +1,30 @@ +"$schema": https://aka.ms/unigetui/package-request.schema.1.0.json +requestVersion: 1.0.0 +requestType: packageOperation +requestId: req-winget-vscode-install-yaml +createdAt: "2026-05-05T12:00:00Z" +operation: install +manager: + name: Winget + displayName: WinGet + executableFriendlyName: winget.exe +source: + name: winget + url: https://cdn.winget.microsoft.com/cache + isVirtualManager: false +package: + id: Microsoft.VisualStudioCode + name: Microsoft Visual Studio Code + architecture: x64 +options: + scope: machine + interactive: false + runAsAdministrator: true + skipHashCheck: false + preRelease: false + customParameters: [] + killBeforeOperation: [] +broker: + requestedElevation: elevated + effectiveUser: 'CONTOSO\alice' + clientVersion: 3.2.0 diff --git a/policies/samples/requests/winget-vscode-interactive.request.json b/policies/samples/requests/winget-vscode-interactive.request.json new file mode 100644 index 0000000000..dd3fefe1d0 --- /dev/null +++ b/policies/samples/requests/winget-vscode-interactive.request.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://aka.ms/unigetui/package-request.schema.1.0.json", + "requestVersion": "1.0.0", + "requestType": "packageOperation", + "requestId": "req-winget-vscode-interactive", + "createdAt": "2026-05-05T12:14:00Z", + "operation": "install", + "manager": { + "name": "Winget", + "displayName": "WinGet", + "executableFriendlyName": "winget.exe" + }, + "source": { + "name": "winget", + "url": "https://cdn.winget.microsoft.com/cache", + "isVirtualManager": false + }, + "package": { + "id": "Microsoft.VisualStudioCode", + "name": "Microsoft Visual Studio Code", + "architecture": "x64" + }, + "options": { + "scope": "machine", + "interactive": true, + "runAsAdministrator": true, + "skipHashCheck": false, + "preRelease": false, + "customParameters": [], + "killBeforeOperation": [] + }, + "broker": { + "requestedElevation": "elevated", + "effectiveUser": "CONTOSO\\alice", + "clientVersion": "3.2.0" + } +} \ No newline at end of file diff --git a/policies/samples/requests/winget-vscode-kill-before.request.json b/policies/samples/requests/winget-vscode-kill-before.request.json new file mode 100644 index 0000000000..e32719a4dd --- /dev/null +++ b/policies/samples/requests/winget-vscode-kill-before.request.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://aka.ms/unigetui/package-request.schema.1.0.json", + "requestVersion": "1.0.0", + "requestType": "packageOperation", + "requestId": "req-winget-vscode-kill-before", + "createdAt": "2026-05-05T12:20:00Z", + "operation": "install", + "manager": { + "name": "Winget", + "displayName": "WinGet", + "executableFriendlyName": "winget.exe" + }, + "source": { + "name": "winget", + "url": "https://cdn.winget.microsoft.com/cache", + "isVirtualManager": false + }, + "package": { + "id": "Microsoft.VisualStudioCode", + "name": "Microsoft Visual Studio Code", + "architecture": "x64" + }, + "options": { + "scope": "machine", + "interactive": false, + "runAsAdministrator": true, + "skipHashCheck": false, + "preRelease": false, + "customParameters": [], + "killBeforeOperation": [ + "Code" + ] + }, + "broker": { + "requestedElevation": "elevated", + "effectiveUser": "CONTOSO\\alice", + "clientVersion": "3.2.0" + } +} \ No newline at end of file diff --git a/policies/samples/requests/winget-vscode-msstore.request.json b/policies/samples/requests/winget-vscode-msstore.request.json new file mode 100644 index 0000000000..f7b72c3ae8 --- /dev/null +++ b/policies/samples/requests/winget-vscode-msstore.request.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://aka.ms/unigetui/package-request.schema.1.0.json", + "requestVersion": "1.0.0", + "requestType": "packageOperation", + "requestId": "req-winget-vscode-msstore", + "createdAt": "2026-05-05T12:18:00Z", + "operation": "install", + "manager": { + "name": "Winget", + "displayName": "WinGet", + "executableFriendlyName": "winget.exe" + }, + "source": { + "name": "msstore", + "url": "https://storeedgefd.dsx.mp.microsoft.com/v9.0", + "isVirtualManager": false + }, + "package": { + "id": "Microsoft.VisualStudioCode", + "name": "Microsoft Visual Studio Code", + "architecture": "x64" + }, + "options": { + "scope": "user", + "interactive": false, + "runAsAdministrator": false, + "skipHashCheck": false, + "preRelease": false, + "customParameters": [], + "killBeforeOperation": [] + }, + "broker": { + "requestedElevation": "standard", + "effectiveUser": "CONTOSO\\alice", + "clientVersion": "3.2.0" + } +} \ No newline at end of file diff --git a/policies/samples/requests/winget-vscode-prepost.request.json b/policies/samples/requests/winget-vscode-prepost.request.json new file mode 100644 index 0000000000..3149f80738 --- /dev/null +++ b/policies/samples/requests/winget-vscode-prepost.request.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://aka.ms/unigetui/package-request.schema.1.0.json", + "requestVersion": "1.0.0", + "requestType": "packageOperation", + "requestId": "req-winget-vscode-prepost", + "createdAt": "2026-05-05T12:19:00Z", + "operation": "install", + "manager": { + "name": "Winget", + "displayName": "WinGet", + "executableFriendlyName": "winget.exe" + }, + "source": { + "name": "winget", + "url": "https://cdn.winget.microsoft.com/cache", + "isVirtualManager": false + }, + "package": { + "id": "Microsoft.VisualStudioCode", + "name": "Microsoft Visual Studio Code", + "architecture": "x64" + }, + "options": { + "scope": "machine", + "interactive": false, + "runAsAdministrator": true, + "skipHashCheck": false, + "preRelease": false, + "preOperationCommand": "Write-Host preparing", + "customParameters": [], + "killBeforeOperation": [] + }, + "broker": { + "requestedElevation": "elevated", + "effectiveUser": "CONTOSO\\alice", + "clientVersion": "3.2.0" + } +} \ No newline at end of file diff --git a/policies/samples/requests/winget-vscode-prerelease.request.json b/policies/samples/requests/winget-vscode-prerelease.request.json new file mode 100644 index 0000000000..7de08332bc --- /dev/null +++ b/policies/samples/requests/winget-vscode-prerelease.request.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://aka.ms/unigetui/package-request.schema.1.0.json", + "requestVersion": "1.0.0", + "requestType": "packageOperation", + "requestId": "req-winget-vscode-prerelease", + "createdAt": "2026-05-05T12:13:00Z", + "operation": "install", + "manager": { + "name": "Winget", + "displayName": "WinGet", + "executableFriendlyName": "winget.exe" + }, + "source": { + "name": "winget", + "url": "https://cdn.winget.microsoft.com/cache", + "isVirtualManager": false + }, + "package": { + "id": "Microsoft.VisualStudioCode", + "name": "Microsoft Visual Studio Code", + "architecture": "x64", + "version": "1.96.0-beta.1" + }, + "options": { + "scope": "machine", + "interactive": false, + "runAsAdministrator": true, + "skipHashCheck": false, + "preRelease": true, + "customParameters": [], + "killBeforeOperation": [] + }, + "broker": { + "requestedElevation": "elevated", + "effectiveUser": "CONTOSO\\alice", + "clientVersion": "3.2.0" + } +} \ No newline at end of file diff --git a/policies/samples/requests/winget-vscode-skiphash.request.json b/policies/samples/requests/winget-vscode-skiphash.request.json new file mode 100644 index 0000000000..2ed40545ab --- /dev/null +++ b/policies/samples/requests/winget-vscode-skiphash.request.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://aka.ms/unigetui/package-request.schema.1.0.json", + "requestVersion": "1.0.0", + "requestType": "packageOperation", + "requestId": "req-winget-vscode-skiphash", + "createdAt": "2026-05-05T12:10:00Z", + "operation": "install", + "manager": { + "name": "Winget", + "displayName": "WinGet", + "executableFriendlyName": "winget.exe" + }, + "source": { + "name": "winget", + "url": "https://cdn.winget.microsoft.com/cache", + "isVirtualManager": false + }, + "package": { + "id": "Microsoft.VisualStudioCode", + "name": "Microsoft Visual Studio Code", + "architecture": "x64" + }, + "options": { + "scope": "machine", + "interactive": false, + "runAsAdministrator": true, + "skipHashCheck": true, + "preRelease": false, + "customParameters": [], + "killBeforeOperation": [] + }, + "broker": { + "requestedElevation": "elevated", + "effectiveUser": "CONTOSO\\alice", + "clientVersion": "3.2.0" + } +} \ No newline at end of file diff --git a/policies/samples/requests/winget-vscode-update-in-range.request.json b/policies/samples/requests/winget-vscode-update-in-range.request.json new file mode 100644 index 0000000000..c4daa6ccf8 --- /dev/null +++ b/policies/samples/requests/winget-vscode-update-in-range.request.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://aka.ms/unigetui/package-request.schema.1.0.json", + "requestVersion": "1.0.0", + "requestType": "packageOperation", + "requestId": "req-winget-vscode-update-in-range", + "createdAt": "2026-05-05T12:12:00Z", + "operation": "update", + "manager": { + "name": "Winget", + "displayName": "WinGet", + "executableFriendlyName": "winget.exe" + }, + "source": { + "name": "winget", + "url": "https://cdn.winget.microsoft.com/cache", + "isVirtualManager": false + }, + "package": { + "id": "Microsoft.VisualStudioCode", + "name": "Microsoft Visual Studio Code", + "architecture": "x64", + "version": "1.96.0" + }, + "options": { + "scope": "machine", + "interactive": false, + "runAsAdministrator": true, + "skipHashCheck": false, + "preRelease": false, + "customParameters": [], + "killBeforeOperation": [] + }, + "broker": { + "requestedElevation": "elevated", + "effectiveUser": "CONTOSO\\alice", + "clientVersion": "3.2.0" + } +} \ No newline at end of file diff --git a/policies/samples/requests/winget-vscode-version-in-range.request.json b/policies/samples/requests/winget-vscode-version-in-range.request.json new file mode 100644 index 0000000000..1470839afe --- /dev/null +++ b/policies/samples/requests/winget-vscode-version-in-range.request.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://aka.ms/unigetui/package-request.schema.1.0.json", + "requestVersion": "1.0.0", + "requestType": "packageOperation", + "requestId": "req-winget-vscode-version-in-range", + "createdAt": "2026-05-05T12:11:00Z", + "operation": "install", + "manager": { + "name": "Winget", + "displayName": "WinGet", + "executableFriendlyName": "winget.exe" + }, + "source": { + "name": "winget", + "url": "https://cdn.winget.microsoft.com/cache", + "isVirtualManager": false + }, + "package": { + "id": "Microsoft.VisualStudioCode", + "name": "Microsoft Visual Studio Code", + "architecture": "x64", + "version": "1.95.0" + }, + "options": { + "scope": "machine", + "interactive": false, + "runAsAdministrator": true, + "skipHashCheck": false, + "preRelease": false, + "customParameters": [], + "killBeforeOperation": [] + }, + "broker": { + "requestedElevation": "elevated", + "effectiveUser": "CONTOSO\\alice", + "clientVersion": "3.2.0" + } +} \ No newline at end of file diff --git a/policies/samples/requests/winget-vscode-version-out-of-range.request.json b/policies/samples/requests/winget-vscode-version-out-of-range.request.json new file mode 100644 index 0000000000..a18d17cf50 --- /dev/null +++ b/policies/samples/requests/winget-vscode-version-out-of-range.request.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://aka.ms/unigetui/package-request.schema.1.0.json", + "requestVersion": "1.0.0", + "requestType": "packageOperation", + "requestId": "req-winget-vscode-version-out-of-range", + "createdAt": "2026-05-05T12:12:00Z", + "operation": "install", + "manager": { + "name": "Winget", + "displayName": "WinGet", + "executableFriendlyName": "winget.exe" + }, + "source": { + "name": "winget", + "url": "https://cdn.winget.microsoft.com/cache", + "isVirtualManager": false + }, + "package": { + "id": "Microsoft.VisualStudioCode", + "name": "Microsoft Visual Studio Code", + "architecture": "x64", + "version": "3.0.0" + }, + "options": { + "scope": "machine", + "interactive": false, + "runAsAdministrator": true, + "skipHashCheck": false, + "preRelease": false, + "customParameters": [], + "killBeforeOperation": [] + }, + "broker": { + "requestedElevation": "elevated", + "effectiveUser": "CONTOSO\\alice", + "clientVersion": "3.2.0" + } +} \ No newline at end of file diff --git a/policies/samples/requests/winget-vscode-winget-fonts.request.json b/policies/samples/requests/winget-vscode-winget-fonts.request.json new file mode 100644 index 0000000000..0d3656b00d --- /dev/null +++ b/policies/samples/requests/winget-vscode-winget-fonts.request.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://aka.ms/unigetui/package-request.schema.1.0.json", + "requestVersion": "1.0.0", + "requestType": "packageOperation", + "requestId": "req-winget-vscode-winget-fonts", + "createdAt": "2026-05-05T12:14:00Z", + "operation": "install", + "manager": { + "name": "Winget", + "displayName": "WinGet", + "executableFriendlyName": "winget.exe" + }, + "source": { + "name": "winget-fonts", + "url": "https://cdn.winget.microsoft.com/cache", + "isVirtualManager": false + }, + "package": { + "id": "Microsoft.VisualStudioCode", + "name": "Microsoft Visual Studio Code", + "architecture": "x64", + "version": "1.95.0" + }, + "options": { + "scope": "machine", + "interactive": false, + "runAsAdministrator": true, + "skipHashCheck": false, + "preRelease": false, + "customParameters": [], + "killBeforeOperation": [] + }, + "broker": { + "requestedElevation": "elevated", + "effectiveUser": "CONTOSO\\alice", + "clientVersion": "3.2.0" + } +} \ No newline at end of file diff --git a/policies/samples/requests/winget-vscode-x86.request.json b/policies/samples/requests/winget-vscode-x86.request.json new file mode 100644 index 0000000000..4582255a6c --- /dev/null +++ b/policies/samples/requests/winget-vscode-x86.request.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://aka.ms/unigetui/package-request.schema.1.0.json", + "requestVersion": "1.0.0", + "requestType": "packageOperation", + "requestId": "req-winget-vscode-x86", + "createdAt": "2026-05-05T12:15:00Z", + "operation": "install", + "manager": { + "name": "Winget", + "displayName": "WinGet", + "executableFriendlyName": "winget.exe" + }, + "source": { + "name": "winget", + "url": "https://cdn.winget.microsoft.com/cache", + "isVirtualManager": false + }, + "package": { + "id": "Microsoft.VisualStudioCode", + "name": "Microsoft Visual Studio Code", + "architecture": "x86", + "version": "1.95.0" + }, + "options": { + "scope": "machine", + "interactive": false, + "runAsAdministrator": true, + "skipHashCheck": false, + "preRelease": false, + "customParameters": [], + "killBeforeOperation": [] + }, + "broker": { + "requestedElevation": "elevated", + "effectiveUser": "CONTOSO\\alice", + "clientVersion": "3.2.0" + } +} \ No newline at end of file diff --git a/policies/samples/responses/status-completed.response.json b/policies/samples/responses/status-completed.response.json new file mode 100644 index 0000000000..fffb131439 --- /dev/null +++ b/policies/samples/responses/status-completed.response.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://aka.ms/unigetui/package-operation-status-response.schema.1.0.json", + "responseVersion": "1.0.0", + "responseType": "packageOperationStatusResponse", + "broker": { + "name": "Devolutions Agent UniGetUI Broker", + "protocolVersion": "1.0", + "transport": "http-named-pipe", + "pipeName": "\\\\.\\pipe\\UniGetUI.PackageBroker.v1", + "elevatedSimulation": false + }, + "requestId": "req-winget-vscode-install", + "status": "completed", + "startedAt": "2026-05-05T12:00:02Z", + "completedAt": "2026-05-05T12:00:15Z", + "exitCode": 0, + "note": "Process exited successfully." +} diff --git a/policies/samples/responses/status-failed.response.json b/policies/samples/responses/status-failed.response.json new file mode 100644 index 0000000000..d1eab601fd --- /dev/null +++ b/policies/samples/responses/status-failed.response.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://aka.ms/unigetui/package-operation-status-response.schema.1.0.json", + "responseVersion": "1.0.0", + "responseType": "packageOperationStatusResponse", + "broker": { + "name": "Devolutions Agent UniGetUI Broker", + "protocolVersion": "1.0", + "transport": "http-named-pipe", + "pipeName": "\\\\.\\pipe\\UniGetUI.PackageBroker.v1", + "elevatedSimulation": false + }, + "requestId": "req-winget-git-uninstall", + "status": "failed", + "startedAt": "2026-05-05T12:01:00Z", + "completedAt": "2026-05-05T12:01:05Z", + "exitCode": 1, + "note": "Process exited with code 1." +} diff --git a/policies/samples/responses/status-running.response.json b/policies/samples/responses/status-running.response.json new file mode 100644 index 0000000000..a2c33ca226 --- /dev/null +++ b/policies/samples/responses/status-running.response.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://aka.ms/unigetui/package-operation-status-response.schema.1.0.json", + "responseVersion": "1.0.0", + "responseType": "packageOperationStatusResponse", + "broker": { + "name": "Devolutions Agent UniGetUI Broker", + "protocolVersion": "1.0", + "transport": "http-named-pipe", + "pipeName": "\\\\.\\pipe\\UniGetUI.PackageBroker.v1", + "elevatedSimulation": false + }, + "requestId": "req-winget-vscode-install", + "status": "running", + "startedAt": "2026-05-05T12:00:02Z" +} diff --git a/policies/samples/responses/status-starting.response.json b/policies/samples/responses/status-starting.response.json new file mode 100644 index 0000000000..87dd5c68ee --- /dev/null +++ b/policies/samples/responses/status-starting.response.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://aka.ms/unigetui/package-operation-status-response.schema.1.0.json", + "responseVersion": "1.0.0", + "responseType": "packageOperationStatusResponse", + "broker": { + "name": "Devolutions Agent UniGetUI Broker", + "protocolVersion": "1.0", + "transport": "http-named-pipe", + "pipeName": "\\\\.\\pipe\\UniGetUI.PackageBroker.v1", + "elevatedSimulation": false + }, + "requestId": "req-abc-123", + "status": "starting" +} diff --git a/policies/samples/responses/status-timeout.response.json b/policies/samples/responses/status-timeout.response.json new file mode 100644 index 0000000000..da6be8b6a2 --- /dev/null +++ b/policies/samples/responses/status-timeout.response.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://aka.ms/unigetui/package-operation-status-response.schema.1.0.json", + "responseVersion": "1.0.0", + "responseType": "packageOperationStatusResponse", + "broker": { + "name": "Devolutions Agent UniGetUI Broker", + "protocolVersion": "1.0", + "transport": "http-named-pipe", + "pipeName": "\\\\.\\pipe\\UniGetUI.PackageBroker.v1", + "elevatedSimulation": false + }, + "requestId": "req-long-running-op", + "status": "failed", + "startedAt": "2026-05-05T11:00:00Z", + "completedAt": "2026-05-05T12:00:00Z", + "note": "Operation timed out after 1 hour." +} diff --git a/policies/samples/responses/winget-vscode-install.allowed.response.json b/policies/samples/responses/winget-vscode-install.allowed.response.json new file mode 100644 index 0000000000..9ae6f339c1 --- /dev/null +++ b/policies/samples/responses/winget-vscode-install.allowed.response.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://aka.ms/unigetui/package-broker-response.schema.1.0.json", + "responseVersion": "1.0.0", + "responseType": "packageBrokerResponse", + "broker": { + "name": "UniGetUI Package Broker", + "protocolVersion": "1.0", + "transport": "http-named-pipe", + "pipeName": "\\\\.\\pipe\\UniGetUI.PackageBroker.v1", + "elevatedSimulation": false + }, + "auditId": "audit-20260505-000001", + "requestId": "req-winget-vscode-install", + "receivedAt": "2026-05-05T12:00:01Z", + "completedAt": "2026-05-05T12:00:01Z", + "manager": "Winget", + "source": "winget", + "packageId": "Microsoft.VisualStudioCode", + "operation": "install", + "decision": "allow", + "ruleId": "allow.winget.vscode", + "reason": "Visual Studio Code is approved for managed workstations.", + "wouldExecute": true, + "policy": { + "id": "contoso.desktop.standard-allowlist", + "revision": 4, + "policyVersion": "1.0.0" + }, + "execution": { + "mode": "elevated", + "command": [ + "winget.exe", + "install", + "--id", + "Microsoft.VisualStudioCode", + "--exact", + "--source", + "winget", + "--scope", + "machine", + "--silent", + "--architecture", + "x64" + ], + "note": "Command was constructed by the broker from validated request fields." + } +} \ No newline at end of file diff --git a/policies/samples/responses/winget-vscode-skiphash.denied.response.json b/policies/samples/responses/winget-vscode-skiphash.denied.response.json new file mode 100644 index 0000000000..f78991ea86 --- /dev/null +++ b/policies/samples/responses/winget-vscode-skiphash.denied.response.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://aka.ms/unigetui/package-broker-response.schema.1.0.json", + "responseVersion": "1.0.0", + "responseType": "packageBrokerResponse", + "broker": { + "name": "UniGetUI Package Broker", + "protocolVersion": "1.0", + "transport": "http-named-pipe", + "pipeName": "\\\\.\\pipe\\UniGetUI.PackageBroker.v1", + "elevatedSimulation": false + }, + "auditId": "audit-20260505-000002", + "requestId": "req-winget-vscode-skiphash", + "receivedAt": "2026-05-05T12:10:01Z", + "completedAt": "2026-05-05T12:10:01Z", + "manager": "Winget", + "source": "winget", + "packageId": "Microsoft.VisualStudioCode", + "operation": "install", + "decision": "deny", + "ruleId": "deny.integrity-bypass", + "reason": "Integrity and publisher checks cannot be bypassed by brokered requests.", + "wouldExecute": false, + "policy": { + "id": "contoso.desktop.standard-allowlist", + "revision": 4, + "policyVersion": "1.0.0" + }, + "execution": { + "mode": "elevated", + "command": [], + "note": "Policy denied the request; the broker must not execute a package manager command." + } +} \ No newline at end of file diff --git a/policies/samples/scenario-coverage.policy.json b/policies/samples/scenario-coverage.policy.json new file mode 100644 index 0000000000..6aa658b60e --- /dev/null +++ b/policies/samples/scenario-coverage.policy.json @@ -0,0 +1,257 @@ +{ + "$schema": "https://aka.ms/unigetui/package-policy.schema.1.0.json", + "policyVersion": "1.0.0", + "policyType": "packageBrokerPolicy", + "metadata": { + "id": "contoso.desktop.scenario-coverage", + "publisher": "Contoso IT", + "revision": 1, + "publishedAt": "2026-05-05T00:00:00Z", + "description": "Focused policy used to exercise simulator precedence, version, and constraint behavior." + }, + "enforcement": { + "defaultDecision": "deny", + "rulePrecedence": "priorityThenDeny" + }, + "rules": [ + { + "id": "deny.disabled-powertoys", + "enabled": false, + "priority": 1, + "decision": "deny", + "reason": "Disabled rules must not participate in policy decisions.", + "match": { + "managers": [ + "Winget" + ], + "packageIdentifiers": [ + "Microsoft.PowerToys" + ] + } + }, + { + "id": "deny.interactive", + "priority": 5, + "decision": "deny", + "reason": "Interactive brokered installs are not allowed in the scenario coverage policy.", + "match": { + "interactive": [ + true + ] + } + }, + { + "id": "deny.tie.custom-parameters", + "priority": 10, + "decision": "deny", + "reason": "Deny must win when allow and deny rules match at the same priority.", + "match": { + "managers": [ + "Winget" + ], + "packageIdentifiers": [ + "Microsoft.VisualStudioCode" + ], + "hasCustomParameters": [ + true + ] + } + }, + { + "id": "allow.tie.vscode-custom-parameters", + "priority": 10, + "decision": "allow", + "reason": "This intentionally ties the deny rule to prove deny wins ties.", + "match": { + "managers": [ + "Winget" + ], + "packageIdentifiers": [ + "Microsoft.VisualStudioCode" + ], + "hasCustomParameters": [ + true + ] + }, + "constraints": { + "allowCustomParameters": true + } + }, + { + "id": "deny.prepost-commands", + "priority": 20, + "decision": "deny", + "reason": "Pre and post commands are outside the package manager trust boundary.", + "match": { + "hasPrePostCommands": [ + true + ] + } + }, + { + "id": "deny.kill-process-actions", + "priority": 30, + "decision": "deny", + "reason": "Killing processes before a brokered operation is not allowed.", + "match": { + "hasKillBeforeOperation": [ + true + ] + } + }, + { + "id": "allow.winget.powertoys", + "priority": 100, + "decision": "allow", + "reason": "PowerToys is allowed and proves disabled deny rules are ignored.", + "match": { + "operations": [ + "install", + "update" + ], + "managers": [ + "Winget" + ], + "sources": [ + "winget" + ], + "packageIdentifiers": [ + "Microsoft.PowerToys" + ], + "scopes": [ + "machine" + ], + "architectures": [ + "x64" + ] + }, + "constraints": { + "allowInteractive": false, + "allowSkipHashCheck": false, + "allowPreRelease": false, + "allowCustomParameters": false, + "allowPrePostCommands": false, + "allowKillBeforeOperation": false + } + }, + { + "id": "allow.winget.vscode.version-range", + "priority": 100, + "decision": "allow", + "reason": "VS Code is allowed only within the tested version range.", + "match": { + "operations": [ + "install", + "update" + ], + "managers": [ + "Winget" + ], + "sources": [ + "winget" + ], + "packageIdentifiers": [ + "Microsoft.VisualStudioCode" + ], + "versionRange": { + "minVersion": "1.90.0", + "maxVersion": "2.0.0", + "includePrerelease": false + }, + "scopes": [ + "machine" + ], + "architectures": [ + "x64" + ] + }, + "constraints": { + "allowInteractive": false, + "allowSkipHashCheck": false, + "allowPreRelease": false, + "allowCustomParameters": false, + "allowPrePostCommands": false, + "allowKillBeforeOperation": false + } + }, + { + "id": "allow.winget.git.customized", + "priority": 100, + "decision": "allow", + "reason": "Git is allowed with tightly constrained customization.", + "match": { + "operations": [ + "install", + "update" + ], + "managers": [ + "Winget" + ], + "sources": [ + "winget" + ], + "packageIdentifiers": [ + "Git.Git" + ], + "scopes": [ + "machine" + ], + "architectures": [ + "x64" + ] + }, + "constraints": { + "allowInteractive": false, + "allowSkipHashCheck": false, + "allowPreRelease": false, + "allowCustomInstallLocation": true, + "allowedInstallLocationPatterns": [ + "C:\\Tools\\Git*" + ], + "allowCustomParameters": true, + "allowedCustomParameters": [ + "--accept-source-agreements" + ], + "deniedCustomParameters": [ + "--override*" + ], + "allowPrePostCommands": false, + "allowKillBeforeOperation": false + } + }, + { + "id": "allow.winget.git.uninstall", + "priority": 100, + "decision": "allow", + "reason": "Git uninstall is allowed for the same corporate package source and machine scope.", + "match": { + "operations": [ + "uninstall" + ], + "managers": [ + "Winget" + ], + "sources": [ + "winget" + ], + "packageIdentifiers": [ + "Git.Git" + ], + "scopes": [ + "machine" + ], + "architectures": [ + "x64" + ] + }, + "constraints": { + "allowInteractive": false, + "allowSkipHashCheck": false, + "allowPreRelease": false, + "allowCustomParameters": false, + "allowPrePostCommands": false, + "allowKillBeforeOperation": false + } + } + ] +} \ No newline at end of file diff --git a/policies/samples/scenarios/baseline.scenarios.json b/policies/samples/scenarios/baseline.scenarios.json new file mode 100644 index 0000000000..d72b8b59eb --- /dev/null +++ b/policies/samples/scenarios/baseline.scenarios.json @@ -0,0 +1,102 @@ +{ + "scenarioSet": "baseline", + "description": "Existing baseline scenarios for package broker policy simulation.", + "scenarios": [ + { + "id": "baseline.winget.vscode.allow", + "policy": "corporate-allowlist.policy.json", + "request": "requests/winget-vscode-install.request.json", + "expectedDecision": "allow", + "expectedRuleId": "allow.winget.vscode", + "tags": ["baseline", "winget", "allowlist"] + }, + { + "id": "baseline.winget.unknown.default-deny", + "policy": "corporate-allowlist.policy.json", + "request": "requests/winget-unknown-install.request.json", + "expectedDecision": "deny", + "expectedRuleId": "", + "tags": ["baseline", "winget", "default-deny"] + }, + { + "id": "baseline.winget.skiphash.deny", + "policy": "corporate-allowlist.policy.json", + "request": "requests/winget-vscode-skiphash.request.json", + "expectedDecision": "deny", + "expectedRuleId": "deny.integrity-bypass", + "tags": ["baseline", "winget", "risky-options"] + }, + { + "id": "baseline.default-allow.normal-install", + "policy": "deny-risky-options.policy.json", + "request": "requests/winget-vscode-install.request.json", + "expectedDecision": "allow", + "expectedRuleId": "", + "tags": ["baseline", "winget", "default-allow"] + }, + { + "id": "baseline.default-allow.skiphash.deny", + "policy": "deny-risky-options.policy.json", + "request": "requests/winget-vscode-skiphash.request.json", + "expectedDecision": "deny", + "expectedRuleId": "deny.integrity-bypass", + "tags": ["baseline", "winget", "risky-options"] + }, + { + "id": "baseline.default-allow.custom-parameters.deny", + "policy": "deny-risky-options.policy.json", + "request": "requests/winget-vscode-custom-param.request.json", + "expectedDecision": "deny", + "expectedRuleId": "deny.manager-custom-parameters", + "tags": ["baseline", "winget", "risky-options"] + }, + { + "id": "baseline.default-allow.msstore.deny", + "policy": "deny-risky-options.policy.json", + "request": "requests/winget-vscode-msstore.request.json", + "expectedDecision": "deny", + "expectedRuleId": "deny.unapproved-winget-source", + "tags": ["baseline", "winget", "source"] + }, + { + "id": "baseline.powershell.pester.currentuser.allow", + "policy": "powershell-current-user.policy.json", + "request": "requests/powershell-pester-currentuser.request.json", + "expectedDecision": "allow", + "expectedRuleId": "allow.powershell.pester", + "tags": ["baseline", "powershell", "scope"] + }, + { + "id": "baseline.powershell.pester.allusers.deny", + "policy": "powershell-current-user.policy.json", + "request": "requests/powershell-pester-allusers.request.json", + "expectedDecision": "deny", + "expectedRuleId": "deny.powershell.machine-scope", + "tags": ["baseline", "powershell", "scope"] + }, + { + "id": "baseline.yaml.policy-yaml.request-yaml.allow", + "policy": "corporate-allowlist.policy.yaml", + "request": "requests/winget-vscode-install.request.yaml", + "expectedDecision": "allow", + "expectedRuleId": "allow.winget.vscode", + "tags": ["baseline", "yaml", "winget"] + }, + { + "id": "baseline.yaml.policy-yaml.request-json.deny", + "policy": "corporate-allowlist.policy.yaml", + "request": "requests/winget-vscode-skiphash.request.json", + "expectedDecision": "deny", + "expectedRuleId": "deny.integrity-bypass", + "tags": ["baseline", "yaml", "winget", "risky-options"] + }, + { + "id": "baseline.yaml.policy-json.request-yaml.allow", + "policy": "corporate-allowlist.policy.json", + "request": "requests/winget-vscode-install.request.yaml", + "expectedDecision": "allow", + "expectedRuleId": "allow.winget.vscode", + "tags": ["baseline", "yaml", "winget"] + } + ] +} \ No newline at end of file diff --git a/policies/samples/scenarios/extended.scenarios.json b/policies/samples/scenarios/extended.scenarios.json new file mode 100644 index 0000000000..4d3e2fa9e3 --- /dev/null +++ b/policies/samples/scenarios/extended.scenarios.json @@ -0,0 +1,198 @@ +{ + "scenarioSet": "extended", + "description": "Extended scenarios for precedence, versioning, constraints, risky options, and validation failures.", + "scenarios": [ + { + "id": "extended.precedence.deny-wins-tie", + "policy": "scenario-coverage.policy.json", + "request": "requests/winget-vscode-custom-param.request.json", + "expectedDecision": "deny", + "expectedRuleId": "deny.tie.custom-parameters", + "tags": ["extended", "precedence", "winget"] + }, + { + "id": "extended.precedence.disabled-rule-ignored", + "policy": "scenario-coverage.policy.json", + "request": "requests/winget-powertoys-install.request.json", + "expectedDecision": "allow", + "expectedRuleId": "allow.winget.powertoys", + "tags": ["extended", "precedence", "winget"] + }, + { + "id": "extended.version.range.allow", + "policy": "scenario-coverage.policy.json", + "request": "requests/winget-vscode-version-in-range.request.json", + "expectedDecision": "allow", + "expectedRuleId": "allow.winget.vscode.version-range", + "tags": ["extended", "version", "winget"] + }, + { + "id": "extended.operation.update.allow", + "policy": "scenario-coverage.policy.json", + "request": "requests/winget-vscode-update-in-range.request.json", + "expectedDecision": "allow", + "expectedRuleId": "allow.winget.vscode.version-range", + "tags": ["extended", "operation", "update", "winget"] + }, + { + "id": "extended.operation.uninstall.allow", + "policy": "scenario-coverage.policy.json", + "request": "requests/winget-git-uninstall.request.json", + "expectedDecision": "allow", + "expectedRuleId": "allow.winget.git.uninstall", + "tags": ["extended", "operation", "uninstall", "winget"] + }, + { + "id": "extended.version.range.default-deny", + "policy": "scenario-coverage.policy.json", + "request": "requests/winget-vscode-version-out-of-range.request.json", + "expectedDecision": "deny", + "expectedRuleId": "", + "tags": ["extended", "version", "winget", "default-deny"] + }, + { + "id": "extended.match.source.default-deny", + "policy": "scenario-coverage.policy.json", + "request": "requests/winget-vscode-winget-fonts.request.json", + "expectedDecision": "deny", + "expectedRuleId": "", + "tags": ["extended", "source", "winget", "default-deny"] + }, + { + "id": "extended.match.architecture.default-deny", + "policy": "scenario-coverage.policy.json", + "request": "requests/winget-vscode-x86.request.json", + "expectedDecision": "deny", + "expectedRuleId": "", + "tags": ["extended", "architecture", "winget", "default-deny"] + }, + { + "id": "extended.version.prerelease.default-deny", + "policy": "scenario-coverage.policy.json", + "request": "requests/winget-vscode-prerelease.request.json", + "expectedDecision": "deny", + "expectedRuleId": "", + "tags": ["extended", "version", "winget", "prerelease"] + }, + { + "id": "extended.risky.interactive.deny", + "policy": "scenario-coverage.policy.json", + "request": "requests/winget-vscode-interactive.request.json", + "expectedDecision": "deny", + "expectedRuleId": "deny.interactive", + "tags": ["extended", "risky-options", "winget"] + }, + { + "id": "extended.constraint.install-location.allow", + "policy": "scenario-coverage.policy.json", + "request": "requests/winget-git-custom-location-allowed.request.json", + "expectedDecision": "allow", + "expectedRuleId": "allow.winget.git.customized", + "tags": ["extended", "constraints", "winget"] + }, + { + "id": "extended.constraint.install-location.default-deny", + "policy": "scenario-coverage.policy.json", + "request": "requests/winget-git-custom-location-denied.request.json", + "expectedDecision": "deny", + "expectedRuleId": "", + "tags": ["extended", "constraints", "winget", "default-deny"] + }, + { + "id": "extended.constraint.custom-parameter.allow", + "policy": "scenario-coverage.policy.json", + "request": "requests/winget-git-custom-param-allowed.request.json", + "expectedDecision": "allow", + "expectedRuleId": "allow.winget.git.customized", + "tags": ["extended", "constraints", "winget"] + }, + { + "id": "extended.constraint.custom-parameter.default-deny", + "policy": "scenario-coverage.policy.json", + "request": "requests/winget-git-custom-param-denied.request.json", + "expectedDecision": "deny", + "expectedRuleId": "", + "tags": ["extended", "constraints", "winget", "default-deny"] + }, + { + "id": "extended.risky.prepost.deny", + "policy": "scenario-coverage.policy.json", + "request": "requests/winget-vscode-prepost.request.json", + "expectedDecision": "deny", + "expectedRuleId": "deny.prepost-commands", + "tags": ["extended", "risky-options", "winget"] + }, + { + "id": "extended.risky.kill-before-operation.deny", + "policy": "scenario-coverage.policy.json", + "request": "requests/winget-vscode-kill-before.request.json", + "expectedDecision": "deny", + "expectedRuleId": "deny.kill-process-actions", + "tags": ["extended", "risky-options", "winget"] + }, + { + "id": "extended.powershell.prerelease.deny", + "policy": "powershell-current-user.policy.json", + "request": "requests/powershell-pester-prerelease.request.json", + "expectedDecision": "deny", + "expectedRuleId": "deny.powershell.prerelease", + "tags": ["extended", "powershell", "prerelease"] + }, + { + "id": "extended.powershell.publisher-bypass.deny", + "policy": "deny-risky-options.policy.json", + "request": "requests/powershell-pester-skipcheck.request.json", + "expectedDecision": "deny", + "expectedRuleId": "deny.integrity-bypass", + "tags": ["extended", "powershell", "risky-options"] + }, + { + "id": "extended.powershell.source.deny", + "policy": "powershell-advanced.policy.json", + "request": "requests/powershell-pester-poshtestgallery.request.json", + "expectedDecision": "deny", + "expectedRuleId": "deny.powershell.untrusted-source", + "tags": ["extended", "powershell", "source"] + }, + { + "id": "extended.powershell.version.allow", + "policy": "powershell-advanced.policy.json", + "request": "requests/powershell-pester-version-allowed.request.json", + "expectedDecision": "allow", + "expectedRuleId": "allow.powershell.pester.versioned", + "tags": ["extended", "powershell", "version"] + }, + { + "id": "extended.powershell.version.default-deny", + "policy": "powershell-advanced.policy.json", + "request": "requests/powershell-pester-version-out-of-range.request.json", + "expectedDecision": "deny", + "expectedRuleId": "", + "tags": ["extended", "powershell", "version", "default-deny"] + }, + { + "id": "extended.powershell.update.allow", + "policy": "powershell-advanced.policy.json", + "request": "requests/powershell-pester-update-currentuser.request.json", + "expectedDecision": "allow", + "expectedRuleId": "allow.powershell.pester.versioned", + "tags": ["extended", "powershell", "operation", "update"] + }, + { + "id": "extended.validation.request-missing-package-id.deny", + "policy": "corporate-allowlist.policy.json", + "request": "invalid/requests/missing-package-id.request.json", + "expectedDecision": "deny", + "expectedRuleId": "", + "tags": ["extended", "validation", "fail-closed"] + }, + { + "id": "extended.validation.policy-invalid-failure-decision.deny", + "policy": "invalid/policies/invalid-failure-decision.policy.json", + "request": "requests/winget-vscode-install.request.json", + "expectedDecision": "deny", + "expectedRuleId": "", + "tags": ["extended", "validation", "fail-closed"] + } + ] +} \ No newline at end of file diff --git a/policies/samples/wire/named-pipe-allow.http b/policies/samples/wire/named-pipe-allow.http new file mode 100644 index 0000000000..e7d908facd --- /dev/null +++ b/policies/samples/wire/named-pipe-allow.http @@ -0,0 +1,38 @@ +POST /v1/package-operations/evaluate HTTP/1.1 +Host: unigetui-broker +UniGetUI-Protocol-Version: 1.0 +UniGetUI-Request-Id: req-winget-vscode-install +Content-Type: application/vnd.unigetui.package-request+json; version=1.0 +Accept: application/vnd.unigetui.package-broker-response+json; version=1.0 +Content-Length: + +{ + "requestVersion": "1.0.0", + "requestType": "packageOperation", + "requestId": "req-winget-vscode-install", + "createdAt": "2026-05-05T12:00:00Z", + "operation": "install", + "manager": { "name": "Winget", "displayName": "WinGet", "executableFriendlyName": "winget.exe" }, + "source": { "name": "winget", "url": "https://cdn.winget.microsoft.com/cache", "isVirtualManager": false }, + "package": { "id": "Microsoft.VisualStudioCode", "name": "Microsoft Visual Studio Code" }, + "options": { + "scope": "machine", + "architecture": "x64", + "interactive": false, + "skipHashCheck": false, + "preRelease": false, + "customParameters": [], + "killBeforeOperation": [] + }, + "broker": { "requestedElevation": "elevated", "effectiveUser": "CONTOSO\\alice", "clientVersion": "3.2.0" } +} + +HTTP/1.1 200 OK +UniGetUI-Protocol-Version: 1.0 +UniGetUI-Audit-Id: audit-20260505-000001 +UniGetUI-Policy-Id: contoso.desktop.standard-allowlist +UniGetUI-Policy-Revision: 4 +Content-Type: application/vnd.unigetui.package-broker-response+json; version=1.0 +Content-Length: + + \ No newline at end of file diff --git a/policies/samples/wire/named-pipe-deny.http b/policies/samples/wire/named-pipe-deny.http new file mode 100644 index 0000000000..e37d88d8a7 --- /dev/null +++ b/policies/samples/wire/named-pipe-deny.http @@ -0,0 +1,38 @@ +POST /v1/package-operations/evaluate HTTP/1.1 +Host: unigetui-broker +UniGetUI-Protocol-Version: 1.0 +UniGetUI-Request-Id: req-winget-vscode-skiphash +Content-Type: application/vnd.unigetui.package-request+json; version=1.0 +Accept: application/vnd.unigetui.package-broker-response+json; version=1.0 +Content-Length: + +{ + "requestVersion": "1.0.0", + "requestType": "packageOperation", + "requestId": "req-winget-vscode-skiphash", + "createdAt": "2026-05-05T12:10:00Z", + "operation": "install", + "manager": { "name": "Winget", "displayName": "WinGet", "executableFriendlyName": "winget.exe" }, + "source": { "name": "winget", "url": "https://cdn.winget.microsoft.com/cache", "isVirtualManager": false }, + "package": { "id": "Microsoft.VisualStudioCode", "name": "Microsoft Visual Studio Code" }, + "options": { + "scope": "machine", + "architecture": "x64", + "interactive": false, + "skipHashCheck": true, + "preRelease": false, + "customParameters": [], + "killBeforeOperation": [] + }, + "broker": { "requestedElevation": "elevated", "effectiveUser": "CONTOSO\\alice", "clientVersion": "3.2.0" } +} + +HTTP/1.1 403 Forbidden +UniGetUI-Protocol-Version: 1.0 +UniGetUI-Audit-Id: audit-20260505-000002 +UniGetUI-Policy-Id: contoso.desktop.standard-allowlist +UniGetUI-Policy-Revision: 4 +Content-Type: application/vnd.unigetui.package-broker-response+json; version=1.0 +Content-Length: + + \ No newline at end of file diff --git a/policies/schemas/unigetui.package-broker-response.schema.json b/policies/schemas/unigetui.package-broker-response.schema.json new file mode 100644 index 0000000000..9585726777 --- /dev/null +++ b/policies/schemas/unigetui.package-broker-response.schema.json @@ -0,0 +1,359 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "brokerInfo": { + "additionalProperties": false, + "description": "Broker identity information in responses.", + "properties": { + "elevatedSimulation": { + "description": "Whether the broker is running in simulated elevation mode.", + "type": "boolean" + }, + "name": { + "description": "Broker display name.", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "pipeName": { + "description": "Named pipe path (when transport is http-named-pipe).", + "maxLength": 256, + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "protocolVersion": { + "allOf": [ + { + "$ref": "#/definitions/protocolVersion" + } + ], + "description": "Protocol version (e.g., \"1.0\")." + }, + "transport": { + "allOf": [ + { + "$ref": "#/definitions/transport" + } + ], + "description": "Transport mechanism." + } + }, + "required": [ + "elevatedSimulation", + "name", + "protocolVersion", + "transport" + ], + "type": "object" + }, + "commandString": { + "maxLength": 2048, + "minLength": 1, + "type": "string" + }, + "decision": { + "description": "Policy decision.", + "enum": [ + "allow", + "deny" + ], + "type": "string" + }, + "executionInfo": { + "additionalProperties": false, + "description": "Execution outcome details.", + "properties": { + "command": { + "description": "Command that was or would be executed.", + "items": { + "$ref": "#/definitions/commandString" + }, + "maxItems": 256, + "type": "array" + }, + "mode": { + "allOf": [ + { + "$ref": "#/definitions/executionMode" + } + ], + "description": "Execution mode." + }, + "note": { + "description": "Additional note about execution.", + "maxLength": 2048, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "command", + "mode", + "note" + ], + "type": "object" + }, + "executionMode": { + "description": "Execution mode.", + "enum": [ + "simulated-elevated", + "elevated" + ], + "type": "string" + }, + "operation": { + "description": "Package operation type.", + "enum": [ + "install", + "update", + "uninstall" + ], + "type": "string" + }, + "packageBrokerResponse": { + "enum": [ + "packageBrokerResponse" + ], + "type": "string" + }, + "packageIdentifier": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[^\\/:*?\"<>|\\x01-\\x1f]+$", + "type": "string" + }, + "protocolVersion": { + "pattern": "^[0-9]+\\.[0-9]+$", + "type": "string" + }, + "resourceId": { + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:\\-]{0,127}$", + "type": "string" + }, + "responsePolicyInfo": { + "additionalProperties": false, + "description": "Summary of policy used for the decision.", + "properties": { + "id": { + "allOf": [ + { + "$ref": "#/definitions/resourceId" + } + ], + "description": "Policy document identifier." + }, + "policyVersion": { + "allOf": [ + { + "$ref": "#/definitions/semanticVersion" + } + ], + "description": "Policy syntax version." + }, + "revision": { + "description": "Policy revision number.", + "format": "uint32", + "maximum": 2147483647.0, + "minimum": 1.0, + "type": "integer" + } + }, + "required": [ + "id", + "policyVersion", + "revision" + ], + "type": "object" + }, + "responseSchemaUri": { + "enum": [ + "https://aka.ms/unigetui/package-broker-response.schema.1.0.json" + ], + "type": "string" + }, + "ruleId": { + "maxLength": 128, + "pattern": "^(||[A-Za-z0-9][A-Za-z0-9._:\\-]{0,127})$", + "type": "string" + }, + "semanticVersion": { + "maxLength": 128, + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$", + "type": "string" + }, + "transport": { + "description": "Broker transport type.", + "enum": [ + "http-named-pipe", + "http-loopback-simulator" + ], + "type": "string" + } + }, + "description": "Canonical response returned by the broker after evaluating a request.", + "properties": { + "$schema": { + "allOf": [ + { + "$ref": "#/definitions/responseSchemaUri" + } + ], + "description": "Response schema URI constant." + }, + "auditId": { + "allOf": [ + { + "$ref": "#/definitions/resourceId" + } + ], + "description": "Server-generated audit identifier." + }, + "broker": { + "allOf": [ + { + "$ref": "#/definitions/brokerInfo" + } + ], + "description": "Broker identity and capabilities." + }, + "completedAt": { + "description": "UTC timestamp when broker completed evaluation (RFC 3339).", + "format": "date-time", + "type": "string" + }, + "decision": { + "allOf": [ + { + "$ref": "#/definitions/decision" + } + ], + "description": "The evaluation decision." + }, + "execution": { + "allOf": [ + { + "$ref": "#/definitions/executionInfo" + } + ], + "description": "Execution details." + }, + "manager": { + "description": "Manager name from the request (null if not parsed).", + "maxLength": 256, + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "operation": { + "anyOf": [ + { + "$ref": "#/definitions/operation" + }, + { + "type": "null" + } + ], + "description": "Operation from the request (null if not parsed)." + }, + "packageId": { + "anyOf": [ + { + "$ref": "#/definitions/packageIdentifier" + }, + { + "type": "null" + } + ], + "description": "Package identifier from the request (null if not parsed)." + }, + "policy": { + "allOf": [ + { + "$ref": "#/definitions/responsePolicyInfo" + } + ], + "description": "Summary of the policy used." + }, + "reason": { + "description": "Human-readable reason for the decision.", + "maxLength": 2048, + "minLength": 1, + "type": "string" + }, + "receivedAt": { + "description": "UTC timestamp when broker received the request (RFC 3339).", + "format": "date-time", + "type": "string" + }, + "requestId": { + "allOf": [ + { + "$ref": "#/definitions/resourceId" + } + ], + "description": "Echoed request id." + }, + "responseType": { + "allOf": [ + { + "$ref": "#/definitions/packageBrokerResponse" + } + ], + "description": "Must be `\"packageBrokerResponse\"`." + }, + "responseVersion": { + "allOf": [ + { + "$ref": "#/definitions/semanticVersion" + } + ], + "description": "Response syntax version (semver)." + }, + "ruleId": { + "allOf": [ + { + "$ref": "#/definitions/ruleId" + } + ], + "description": "The rule that produced the decision." + }, + "source": { + "description": "Source name from the request (null if not parsed).", + "maxLength": 256, + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "wouldExecute": { + "description": "Whether the broker would execute a command for this decision.", + "type": "boolean" + } + }, + "required": [ + "$schema", + "auditId", + "broker", + "completedAt", + "decision", + "execution", + "policy", + "reason", + "receivedAt", + "requestId", + "responseType", + "responseVersion", + "ruleId", + "wouldExecute" + ], + "title": "brokerResponse", + "type": "object" +} \ No newline at end of file diff --git a/policies/schemas/unigetui.package-operation-status-request.schema.json b/policies/schemas/unigetui.package-operation-status-request.schema.json new file mode 100644 index 0000000000..0e66c12771 --- /dev/null +++ b/policies/schemas/unigetui.package-operation-status-request.schema.json @@ -0,0 +1,129 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "brokerContext": { + "additionalProperties": false, + "description": "Broker context provided by the client.", + "properties": { + "clientProcessPath": { + "description": "File path of the client process.", + "maxLength": 2048, + "type": [ + "string", + "null" + ] + }, + "clientVersion": { + "description": "Version of the UniGetUI client.", + "maxLength": 128, + "type": [ + "string", + "null" + ] + }, + "effectiveUser": { + "description": "Windows identity of the calling user.", + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "requestedElevation": { + "allOf": [ + { + "$ref": "#/definitions/elevation" + } + ], + "description": "Elevation level requested." + } + }, + "required": [ + "effectiveUser", + "requestedElevation" + ], + "type": "object" + }, + "elevation": { + "description": "Requested elevation level.", + "enum": [ + "standard", + "elevated" + ], + "type": "string" + }, + "packageOperationStatus": { + "enum": [ + "packageOperationStatus" + ], + "type": "string" + }, + "resourceId": { + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:\\-]{0,127}$", + "type": "string" + }, + "semanticVersion": { + "maxLength": 128, + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$", + "type": "string" + }, + "statusRequestSchemaUri": { + "enum": [ + "https://aka.ms/unigetui/package-operation-status-request.schema.1.0.json" + ], + "type": "string" + } + }, + "description": "Request to query the status of a previously submitted package operation.", + "properties": { + "$schema": { + "allOf": [ + { + "$ref": "#/definitions/statusRequestSchemaUri" + } + ], + "description": "Status request schema URI constant." + }, + "broker": { + "allOf": [ + { + "$ref": "#/definitions/brokerContext" + } + ], + "description": "Broker context from the client." + }, + "requestId": { + "allOf": [ + { + "$ref": "#/definitions/resourceId" + } + ], + "description": "The `requestId` of the original package operation to query." + }, + "requestType": { + "allOf": [ + { + "$ref": "#/definitions/packageOperationStatus" + } + ], + "description": "Must be `\"packageOperationStatus\"`." + }, + "requestVersion": { + "allOf": [ + { + "$ref": "#/definitions/semanticVersion" + } + ], + "description": "Request syntax version (semver)." + } + }, + "required": [ + "$schema", + "broker", + "requestId", + "requestType", + "requestVersion" + ], + "title": "statusRequest", + "type": "object" +} \ No newline at end of file diff --git a/policies/schemas/unigetui.package-operation-status-response.schema.json b/policies/schemas/unigetui.package-operation-status-response.schema.json new file mode 100644 index 0000000000..ce98418ae4 --- /dev/null +++ b/policies/schemas/unigetui.package-operation-status-response.schema.json @@ -0,0 +1,214 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "brokerInfo": { + "additionalProperties": false, + "description": "Broker identity information in responses.", + "properties": { + "elevatedSimulation": { + "description": "Whether the broker is running in simulated elevation mode.", + "type": "boolean" + }, + "name": { + "description": "Broker display name.", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "pipeName": { + "description": "Named pipe path (when transport is http-named-pipe).", + "maxLength": 256, + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "protocolVersion": { + "allOf": [ + { + "$ref": "#/definitions/protocolVersion" + } + ], + "description": "Protocol version (e.g., \"1.0\")." + }, + "transport": { + "allOf": [ + { + "$ref": "#/definitions/transport" + } + ], + "description": "Transport mechanism." + } + }, + "required": [ + "elevatedSimulation", + "name", + "protocolVersion", + "transport" + ], + "type": "object" + }, + "operationStatus": { + "description": "Status of an asynchronous package operation.", + "oneOf": [ + { + "description": "Process is being prepared/started.", + "enum": [ + "starting" + ], + "type": "string" + }, + { + "description": "Process is running.", + "enum": [ + "running" + ], + "type": "string" + }, + { + "description": "Process exited successfully (exit code 0).", + "enum": [ + "completed" + ], + "type": "string" + }, + { + "description": "Process failed (non-zero exit, timeout, or launch failure).", + "enum": [ + "failed" + ], + "type": "string" + } + ] + }, + "packageOperationStatusResponse": { + "enum": [ + "packageOperationStatusResponse" + ], + "type": "string" + }, + "protocolVersion": { + "pattern": "^[0-9]+\\.[0-9]+$", + "type": "string" + }, + "resourceId": { + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:\\-]{0,127}$", + "type": "string" + }, + "semanticVersion": { + "maxLength": 128, + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$", + "type": "string" + }, + "statusResponseSchemaUri": { + "enum": [ + "https://aka.ms/unigetui/package-operation-status-response.schema.1.0.json" + ], + "type": "string" + }, + "transport": { + "description": "Broker transport type.", + "enum": [ + "http-named-pipe", + "http-loopback-simulator" + ], + "type": "string" + } + }, + "description": "Response to a status query.", + "properties": { + "$schema": { + "allOf": [ + { + "$ref": "#/definitions/statusResponseSchemaUri" + } + ], + "description": "Status response schema URI constant." + }, + "broker": { + "allOf": [ + { + "$ref": "#/definitions/brokerInfo" + } + ], + "description": "Broker identity and capabilities." + }, + "completedAt": { + "description": "UTC timestamp when the operation completed or failed (null if still running).", + "format": "date-time", + "type": [ + "string", + "null" + ] + }, + "exitCode": { + "description": "Process exit code (present when status is `completed`, or `failed` due to non-zero exit).", + "format": "int32", + "type": [ + "integer", + "null" + ] + }, + "note": { + "description": "Human-readable note about the status.", + "maxLength": 2048, + "type": [ + "string", + "null" + ] + }, + "requestId": { + "allOf": [ + { + "$ref": "#/definitions/resourceId" + } + ], + "description": "The original request id being queried." + }, + "responseType": { + "allOf": [ + { + "$ref": "#/definitions/packageOperationStatusResponse" + } + ], + "description": "Must be `\"packageOperationStatusResponse\"`." + }, + "responseVersion": { + "allOf": [ + { + "$ref": "#/definitions/semanticVersion" + } + ], + "description": "Response syntax version (semver)." + }, + "startedAt": { + "description": "UTC timestamp when the process was actually launched (null if not yet started).", + "format": "date-time", + "type": [ + "string", + "null" + ] + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/operationStatus" + } + ], + "description": "Current status of the operation." + } + }, + "required": [ + "$schema", + "broker", + "requestId", + "responseType", + "responseVersion", + "status" + ], + "title": "statusResponse", + "type": "object" +} \ No newline at end of file diff --git a/policies/schemas/unigetui.package-policy.schema.json b/policies/schemas/unigetui.package-policy.schema.json new file mode 100644 index 0000000000..c64cf428f3 --- /dev/null +++ b/policies/schemas/unigetui.package-policy.schema.json @@ -0,0 +1,618 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "architecture": { + "description": "Target architecture.", + "enum": [ + "x86", + "x64", + "arm64", + "neutral" + ], + "type": "string" + }, + "customParameterString": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "decision": { + "description": "Policy decision.", + "enum": [ + "allow", + "deny" + ], + "type": "string" + }, + "elevation": { + "description": "Requested elevation level.", + "enum": [ + "standard", + "elevated" + ], + "type": "string" + }, + "httpUrl": { + "maxLength": 2048, + "pattern": "^([Hh][Tt][Tt][Pp][Ss]?)://.+$", + "type": "string" + }, + "managerName": { + "description": "Supported package manager names.", + "enum": [ + "Winget", + "PowerShell" + ], + "type": "string" + }, + "operation": { + "description": "Package operation type.", + "enum": [ + "install", + "update", + "uninstall" + ], + "type": "string" + }, + "packageBrokerPolicy": { + "enum": [ + "packageBrokerPolicy" + ], + "type": "string" + }, + "policyConstraints": { + "additionalProperties": false, + "description": "Constraints applied after a rule matches.", + "properties": { + "allowCustomInstallLocation": { + "description": "Allow custom install location.", + "type": "boolean" + }, + "allowCustomParameters": { + "description": "Allow custom parameters.", + "type": "boolean" + }, + "allowInteractive": { + "description": "Allow interactive mode.", + "type": "boolean" + }, + "allowKillBeforeOperation": { + "description": "Allow killing processes before operation.", + "type": "boolean" + }, + "allowPrePostCommands": { + "description": "Allow pre/post operation commands.", + "type": "boolean" + }, + "allowPreRelease": { + "description": "Allow pre-release versions.", + "type": "boolean" + }, + "allowSkipHashCheck": { + "description": "Allow skipping hash verification.", + "type": "boolean" + }, + "allowUninstallPrevious": { + "description": "Allow uninstalling previous version before installing update.", + "type": "boolean" + }, + "allowUpgrade": { + "description": "Allow skipping upgrade on install operations if an existing version is detected (for install operations).", + "type": "boolean" + }, + "allowedCustomParameterPatterns": { + "description": "Glob patterns for allowed custom parameters.", + "items": { + "$ref": "#/definitions/customParameterString" + }, + "maxItems": 128, + "type": "array" + }, + "allowedCustomParameters": { + "description": "Exact allowed custom parameters.", + "items": { + "$ref": "#/definitions/customParameterString" + }, + "maxItems": 128, + "type": "array" + }, + "allowedInstallLocationPatterns": { + "description": "Glob patterns for allowed install locations.", + "items": { + "$ref": "#/definitions/stringPattern" + }, + "maxItems": 64, + "type": "array" + }, + "deniedCustomParameters": { + "description": "Denied custom parameters (deny takes precedence over allow).", + "items": { + "$ref": "#/definitions/customParameterString" + }, + "maxItems": 128, + "type": "array" + } + }, + "type": "object" + }, + "policyEnforcement": { + "additionalProperties": false, + "description": "Enforcement configuration.", + "properties": { + "auditMode": { + "description": "When true, broker logs decisions but does not enforce.", + "type": [ + "boolean", + "null" + ] + }, + "defaultDecision": { + "allOf": [ + { + "$ref": "#/definitions/decision" + } + ], + "description": "Decision when no rule matches." + }, + "rulePrecedence": { + "allOf": [ + { + "$ref": "#/definitions/rulePrecedence" + } + ], + "description": "Rule precedence strategy (must be \"priorityThenDeny\")." + } + }, + "required": [ + "defaultDecision", + "rulePrecedence" + ], + "type": "object" + }, + "policyMatch": { + "additionalProperties": false, + "description": "Match criteria for a policy rule. All specified fields must match. At least one field must be present.", + "properties": { + "architectures": { + "description": "Allowed architectures.", + "items": { + "$ref": "#/definitions/architecture" + }, + "maxItems": 5, + "type": "array", + "uniqueItems": true + }, + "elevation": { + "description": "Allowed elevation levels.", + "items": { + "$ref": "#/definitions/elevation" + }, + "maxItems": 2, + "type": "array", + "uniqueItems": true + }, + "hasCustomInstallLocation": { + "description": "Whether request has custom install location.", + "items": { + "type": "boolean" + }, + "maxItems": 2, + "type": "array", + "uniqueItems": true + }, + "hasCustomParameters": { + "description": "Whether request has custom parameters.", + "items": { + "type": "boolean" + }, + "maxItems": 2, + "type": "array", + "uniqueItems": true + }, + "hasKillBeforeOperation": { + "description": "Whether request has kill-before-operation entries.", + "items": { + "type": "boolean" + }, + "maxItems": 2, + "type": "array", + "uniqueItems": true + }, + "hasPrePostCommands": { + "description": "Whether request has pre/post operation commands.", + "items": { + "type": "boolean" + }, + "maxItems": 2, + "type": "array", + "uniqueItems": true + }, + "hasUninstallPrevious": { + "description": "Whether request has uninstall-previous flag set.", + "items": { + "type": "boolean" + }, + "type": "array", + "uniqueItems": true + }, + "interactive": { + "description": "Allowed interactive values.", + "items": { + "type": "boolean" + }, + "maxItems": 2, + "type": "array", + "uniqueItems": true + }, + "managers": { + "description": "Allowed managers.", + "items": { + "$ref": "#/definitions/managerName" + }, + "maxItems": 16, + "type": "array", + "uniqueItems": true + }, + "operations": { + "description": "Allowed operations.", + "items": { + "$ref": "#/definitions/operation" + }, + "maxItems": 3, + "type": "array", + "uniqueItems": true + }, + "packageIdentifiers": { + "description": "Package identifier patterns (wildcard).", + "items": { + "$ref": "#/definitions/stringPattern" + }, + "maxItems": 1024, + "type": "array", + "uniqueItems": true + }, + "packageNames": { + "description": "Package name patterns (wildcard).", + "items": { + "$ref": "#/definitions/stringPattern" + }, + "maxItems": 1024, + "type": "array", + "uniqueItems": true + }, + "preRelease": { + "description": "Allowed preRelease values.", + "items": { + "type": "boolean" + }, + "maxItems": 2, + "type": "array", + "uniqueItems": true + }, + "scopes": { + "description": "Allowed scopes.", + "items": { + "$ref": "#/definitions/scope" + }, + "maxItems": 2, + "type": "array", + "uniqueItems": true + }, + "skipHashCheck": { + "description": "Allowed skipHashCheck values.", + "items": { + "type": "boolean" + }, + "maxItems": 2, + "type": "array", + "uniqueItems": true + }, + "sources": { + "description": "Source patterns (wildcard).", + "items": { + "$ref": "#/definitions/stringPattern" + }, + "maxItems": 128, + "type": "array", + "uniqueItems": true + }, + "versionRange": { + "anyOf": [ + { + "$ref": "#/definitions/versionRange" + }, + { + "type": "null" + } + ], + "description": "Semantic version range." + }, + "versions": { + "description": "Exact version list.", + "items": { + "$ref": "#/definitions/versionString" + }, + "maxItems": 256, + "type": "array", + "uniqueItems": true + } + }, + "type": "object" + }, + "policyMetadata": { + "additionalProperties": false, + "description": "Policy metadata.", + "properties": { + "description": { + "description": "Human-readable description.", + "maxLength": 512, + "type": [ + "string", + "null" + ] + }, + "id": { + "allOf": [ + { + "$ref": "#/definitions/resourceId" + } + ], + "description": "Unique policy identifier." + }, + "publishedAt": { + "description": "ISO 8601 publication timestamp (RFC 3339).", + "format": "date-time", + "type": "string" + }, + "publisher": { + "description": "Organization that published the policy.", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "revision": { + "description": "Monotonically increasing revision number.", + "format": "uint32", + "maximum": 2147483647.0, + "minimum": 1.0, + "type": "integer" + }, + "supportUrl": { + "anyOf": [ + { + "$ref": "#/definitions/httpUrl" + }, + { + "type": "null" + } + ], + "description": "URL for support or documentation." + }, + "validFrom": { + "description": "Policy becomes active at this time.", + "format": "date-time", + "type": [ + "string", + "null" + ] + }, + "validUntil": { + "description": "Policy expires at this time.", + "format": "date-time", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "publishedAt", + "publisher", + "revision" + ], + "type": "object" + }, + "policyRule": { + "additionalProperties": false, + "description": "A single policy rule.", + "properties": { + "constraints": { + "anyOf": [ + { + "$ref": "#/definitions/policyConstraints" + }, + { + "type": "null" + } + ], + "description": "Additional constraints applied after matching. When absent, no constraints are enforced beyond the match criteria." + }, + "decision": { + "allOf": [ + { + "$ref": "#/definitions/decision" + } + ], + "description": "Decision if this rule matches." + }, + "enabled": { + "default": true, + "description": "Whether the rule is active.", + "type": "boolean" + }, + "id": { + "allOf": [ + { + "$ref": "#/definitions/resourceId" + } + ], + "description": "Unique rule identifier." + }, + "match": { + "allOf": [ + { + "$ref": "#/definitions/policyMatch" + } + ], + "description": "Match criteria — request must satisfy all specified fields. At least one criterion must be present." + }, + "priority": { + "description": "Priority (lower = higher precedence).", + "format": "uint32", + "maximum": 2147483647.0, + "minimum": 0.0, + "type": "integer" + }, + "reason": { + "description": "Reason reported to the client.", + "maxLength": 512, + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "decision", + "id", + "match", + "priority" + ], + "type": "object" + }, + "policySchemaUri": { + "enum": [ + "https://aka.ms/unigetui/package-policy.schema.1.0.json" + ], + "type": "string" + }, + "resourceId": { + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:\\-]{0,127}$", + "type": "string" + }, + "rulePrecedence": { + "description": "Rule precedence strategy — always priorityThenDeny.", + "enum": [ + "priorityThenDeny" + ], + "type": "string" + }, + "scope": { + "description": "Package installation scope.", + "enum": [ + "user", + "machine" + ], + "type": "string" + }, + "semanticVersion": { + "maxLength": 128, + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$", + "type": "string" + }, + "stringPattern": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "versionRange": { + "additionalProperties": false, + "description": "Semantic version range for matching.", + "properties": { + "includePrerelease": { + "default": false, + "description": "Whether to include pre-release versions.", + "type": "boolean" + }, + "maxVersion": { + "description": "Maximum version (inclusive).", + "maxLength": 128, + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "minVersion": { + "description": "Minimum version (inclusive).", + "maxLength": 128, + "minLength": 1, + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "versionString": { + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "description": "A policy document governing which package operations are allowed or denied.", + "properties": { + "$schema": { + "allOf": [ + { + "$ref": "#/definitions/policySchemaUri" + } + ], + "description": "Policy schema URI constant." + }, + "enforcement": { + "allOf": [ + { + "$ref": "#/definitions/policyEnforcement" + } + ], + "description": "Enforcement configuration." + }, + "metadata": { + "allOf": [ + { + "$ref": "#/definitions/policyMetadata" + } + ], + "description": "Policy metadata." + }, + "policyType": { + "allOf": [ + { + "$ref": "#/definitions/packageBrokerPolicy" + } + ], + "description": "Must be `\"packageBrokerPolicy\"`." + }, + "policyVersion": { + "allOf": [ + { + "$ref": "#/definitions/semanticVersion" + } + ], + "description": "Policy syntax version (semver)." + }, + "rules": { + "description": "Ordered list of policy rules (may be empty; enforcement defaults apply).", + "items": { + "$ref": "#/definitions/policyRule" + }, + "maxItems": 1024, + "type": "array" + } + }, + "required": [ + "$schema", + "enforcement", + "metadata", + "policyType", + "policyVersion", + "rules" + ], + "title": "policyDocument", + "type": "object" +} \ No newline at end of file diff --git a/policies/schemas/unigetui.package-request.schema.json b/policies/schemas/unigetui.package-request.schema.json new file mode 100644 index 0000000000..59ae6af890 --- /dev/null +++ b/policies/schemas/unigetui.package-request.schema.json @@ -0,0 +1,435 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "architecture": { + "description": "Target architecture.", + "enum": [ + "x86", + "x64", + "arm64", + "neutral" + ], + "type": "string" + }, + "brokerContext": { + "additionalProperties": false, + "description": "Broker context provided by the client.", + "properties": { + "clientProcessPath": { + "description": "File path of the client process.", + "maxLength": 2048, + "type": [ + "string", + "null" + ] + }, + "clientVersion": { + "description": "Version of the UniGetUI client.", + "maxLength": 128, + "type": [ + "string", + "null" + ] + }, + "effectiveUser": { + "description": "Windows identity of the calling user.", + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "requestedElevation": { + "allOf": [ + { + "$ref": "#/definitions/elevation" + } + ], + "description": "Elevation level requested." + } + }, + "required": [ + "effectiveUser", + "requestedElevation" + ], + "type": "object" + }, + "customParameterString": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "elevation": { + "description": "Requested elevation level.", + "enum": [ + "standard", + "elevated" + ], + "type": "string" + }, + "managerName": { + "description": "Supported package manager names.", + "enum": [ + "Winget", + "PowerShell" + ], + "type": "string" + }, + "operation": { + "description": "Package operation type.", + "enum": [ + "install", + "update", + "uninstall" + ], + "type": "string" + }, + "packageIdentifier": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[^\\/:*?\"<>|\\x01-\\x1f]+$", + "type": "string" + }, + "packageOperation": { + "enum": [ + "packageOperation" + ], + "type": "string" + }, + "processName": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "requestManager": { + "additionalProperties": false, + "description": "Package manager metadata from the request.", + "properties": { + "displayName": { + "description": "Human-readable display name.", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "executableFriendlyName": { + "description": "Friendly name of the executable.", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "name": { + "allOf": [ + { + "$ref": "#/definitions/managerName" + } + ], + "description": "Package manager name." + } + }, + "required": [ + "displayName", + "executableFriendlyName", + "name" + ], + "type": "object" + }, + "requestOptions": { + "additionalProperties": false, + "description": "Options controlling the package operation.", + "properties": { + "customInstallLocation": { + "description": "Custom install directory path.", + "maxLength": 2048, + "type": [ + "string", + "null" + ] + }, + "customParameters": { + "description": "Additional command-line parameters.", + "items": { + "$ref": "#/definitions/customParameterString" + }, + "maxItems": 64, + "type": "array" + }, + "interactive": { + "description": "Run interactively (show installer UI).", + "type": "boolean" + }, + "killBeforeOperation": { + "description": "Processes to kill before running the operation.", + "items": { + "$ref": "#/definitions/processName" + }, + "maxItems": 64, + "type": "array" + }, + "noUpgrade": { + "default": false, + "description": "Whether to skip upgrade if an existing version is detected (for install operations).", + "type": "boolean" + }, + "postOperationCommand": { + "description": "Command to execute after the package operation.", + "maxLength": 2048, + "type": [ + "string", + "null" + ] + }, + "preOperationCommand": { + "description": "Command to execute before the package operation.", + "maxLength": 2048, + "type": [ + "string", + "null" + ] + }, + "preRelease": { + "description": "Allow pre-release versions.", + "type": "boolean" + }, + "scope": { + "anyOf": [ + { + "$ref": "#/definitions/scope" + }, + { + "type": "null" + } + ], + "description": "Installation scope." + }, + "skipHashCheck": { + "description": "Skip package hash verification.", + "type": "boolean" + }, + "uninstallPrevious": { + "default": false, + "description": "Whether to uninstall previous version before installing update.", + "type": "boolean" + } + }, + "required": [ + "interactive", + "preRelease", + "skipHashCheck" + ], + "type": "object" + }, + "requestPackage": { + "additionalProperties": false, + "description": "Package information.", + "properties": { + "architecture": { + "anyOf": [ + { + "$ref": "#/definitions/architecture" + }, + { + "type": "null" + } + ], + "description": "Target architecture." + }, + "channel": { + "description": "Release channel.", + "maxLength": 16, + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "id": { + "allOf": [ + { + "$ref": "#/definitions/packageIdentifier" + } + ], + "description": "Package identifier (e.g., \"Publisher.Package\" for WinGet)." + }, + "name": { + "description": "Human-readable package name.", + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "version": { + "anyOf": [ + { + "$ref": "#/definitions/semanticVersion" + }, + { + "type": "null" + } + ], + "description": "Target version (for update/install operations)." + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, + "requestSchemaUri": { + "enum": [ + "https://aka.ms/unigetui/package-request.schema.1.0.json" + ], + "type": "string" + }, + "requestSource": { + "additionalProperties": false, + "description": "Package source/repository information.", + "properties": { + "isVirtualManager": { + "description": "Whether this is a virtual manager (runs without a real CLI).", + "type": [ + "boolean", + "null" + ] + }, + "name": { + "description": "Source name.", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "url": { + "description": "Optional source URL.", + "maxLength": 2048, + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "resourceId": { + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:\\-]{0,127}$", + "type": "string" + }, + "scope": { + "description": "Package installation scope.", + "enum": [ + "user", + "machine" + ], + "type": "string" + }, + "semanticVersion": { + "maxLength": 128, + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$", + "type": "string" + } + }, + "description": "Canonical request sent by an unelevated UniGetUI process to the elevated broker.", + "properties": { + "$schema": { + "allOf": [ + { + "$ref": "#/definitions/requestSchemaUri" + } + ], + "description": "Request schema URI constant." + }, + "broker": { + "allOf": [ + { + "$ref": "#/definitions/brokerContext" + } + ], + "description": "Broker context from the client." + }, + "createdAt": { + "description": "UTC timestamp when the client created the request (RFC 3339).", + "format": "date-time", + "type": "string" + }, + "manager": { + "allOf": [ + { + "$ref": "#/definitions/requestManager" + } + ], + "description": "Package manager information." + }, + "operation": { + "allOf": [ + { + "$ref": "#/definitions/operation" + } + ], + "description": "The package operation to perform." + }, + "options": { + "allOf": [ + { + "$ref": "#/definitions/requestOptions" + } + ], + "description": "Operation options." + }, + "package": { + "allOf": [ + { + "$ref": "#/definitions/requestPackage" + } + ], + "description": "Package information." + }, + "requestId": { + "allOf": [ + { + "$ref": "#/definitions/resourceId" + } + ], + "description": "Unique client-generated request id for audit correlation." + }, + "requestType": { + "allOf": [ + { + "$ref": "#/definitions/packageOperation" + } + ], + "description": "Must be `\"packageOperation\"`." + }, + "requestVersion": { + "allOf": [ + { + "$ref": "#/definitions/semanticVersion" + } + ], + "description": "The request syntax version (semver)." + }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/requestSource" + } + ], + "description": "Source/repository information." + } + }, + "required": [ + "$schema", + "broker", + "createdAt", + "manager", + "operation", + "options", + "package", + "requestId", + "requestType", + "requestVersion", + "source" + ], + "title": "packageRequest", + "type": "object" +} \ No newline at end of file diff --git a/policies/scripts/Invoke-UniGetUIPolicySimulation.ps1 b/policies/scripts/Invoke-UniGetUIPolicySimulation.ps1 new file mode 100644 index 0000000000..b9302da16b --- /dev/null +++ b/policies/scripts/Invoke-UniGetUIPolicySimulation.ps1 @@ -0,0 +1,58 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string] $PolicyPath, + + [Parameter(Mandatory = $true)] + [string[]] $RequestPath, + + [string] $PolicySchemaPath, + + [string] $RequestSchemaPath, + + [switch] $AsJson +) + +Set-StrictMode -Version 2.0 + +$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$policyRoot = Resolve-Path -LiteralPath (Join-Path $scriptRoot "..") + +if ([string]::IsNullOrWhiteSpace($PolicySchemaPath)) { + $PolicySchemaPath = Join-Path $policyRoot.Path "schemas\unigetui.package-policy.schema.1.0.json" +} +if ([string]::IsNullOrWhiteSpace($RequestSchemaPath)) { + $RequestSchemaPath = Join-Path $policyRoot.Path "schemas\unigetui.package-request.schema.1.0.json" +} + +Import-Module (Join-Path $scriptRoot "UniGetUIPolicySimulation.psm1") -Force + +$expandedRequestPaths = @() +foreach ($pathArgument in $RequestPath) { + $splitPaths = ([string] $pathArgument) -split "," + foreach ($path in $splitPaths) { + $trimmedPath = $path.Trim() + if ([string]::IsNullOrWhiteSpace($trimmedPath)) { + continue + } + + $matches = Resolve-Path -Path $trimmedPath -ErrorAction Stop + foreach ($match in @($matches)) { + $expandedRequestPaths += $match.Path + } + } +} + +$results = @() +foreach ($path in $expandedRequestPaths) { + $results += Invoke-UniGetUIPolicyFileDecision -PolicyPath $PolicyPath -RequestPath $path -PolicySchemaPath $PolicySchemaPath -RequestSchemaPath $RequestSchemaPath +} + +if ($AsJson) { + $results | ConvertTo-Json -Depth 10 + return +} + +$results | + Select-Object RequestId, Manager, Source, PackageId, Operation, Decision, RuleId, Reason | + Format-Table -AutoSize \ No newline at end of file diff --git a/policies/scripts/Test-UniGetUIPolicySamples.ps1 b/policies/scripts/Test-UniGetUIPolicySamples.ps1 new file mode 100644 index 0000000000..6c0f95487a --- /dev/null +++ b/policies/scripts/Test-UniGetUIPolicySamples.ps1 @@ -0,0 +1,171 @@ +[CmdletBinding()] +param( + [string[]] $ScenarioPath, + + [string[]] $Tag, + + [switch] $List +) + +Set-StrictMode -Version 2.0 + +$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$policyRoot = Resolve-Path -LiteralPath (Join-Path $scriptRoot "..") + +Import-Module (Join-Path $scriptRoot "UniGetUIPolicySimulation.psm1") -Force + +$policySchemaPath = Join-Path $policyRoot.Path "schemas\unigetui.package-policy.schema.1.0.json" +$requestSchemaPath = Join-Path $policyRoot.Path "schemas\unigetui.package-request.schema.1.0.json" +$sampleRoot = Join-Path $policyRoot.Path "samples" +$scenarioRoot = Join-Path $sampleRoot "scenarios" + +function Resolve-SampleRelativePath { + param( + [Parameter(Mandatory = $true)] + [string] $Path + ) + + if ([System.IO.Path]::IsPathRooted($Path)) { + return (Resolve-Path -LiteralPath $Path -ErrorAction Stop).Path + } + + (Resolve-Path -LiteralPath (Join-Path $sampleRoot $Path) -ErrorAction Stop).Path +} + +function Get-ScenarioManifestPaths { + param( + [string[]] $Paths + ) + + $resolvedPaths = @() + if ($null -eq $Paths -or $Paths.Count -eq 0) { + foreach ($filter in @("*.scenarios.json", "*.scenarios.yaml", "*.scenarios.yml")) { + $resolvedPaths += @(Get-ChildItem -LiteralPath $scenarioRoot -Filter $filter -File -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName) + } + } + else { + foreach ($path in $Paths) { + $matches = Resolve-Path -Path $path -ErrorAction Stop + foreach ($match in @($matches)) { + $resolvedPaths += $match.Path + } + } + } + + @($resolvedPaths | Sort-Object -Unique) +} + +function Test-ScenarioTagMatch { + param( + [object] $ScenarioTags, + [string[]] $RequiredTags + ) + + if ($null -eq $RequiredTags -or $RequiredTags.Count -eq 0) { + return $true + } + + $tags = @($ScenarioTags) + foreach ($requiredTag in $RequiredTags) { + if ($tags -contains $requiredTag) { + return $true + } + } + + $false +} + +$manifestPaths = Get-ScenarioManifestPaths -Paths $ScenarioPath +if ($manifestPaths.Count -eq 0) { + Write-Error "No scenario manifests were found." + exit 1 +} + +$scenarios = @() +foreach ($manifestPath in $manifestPaths) { + $manifest = Read-UniGetUIDocumentFile -Path $manifestPath + $manifestScenarios = Get-ObjectPropertyValue -InputObject $manifest.Data -Name "scenarios" + if ($null -eq $manifestScenarios) { + Write-Error "Scenario manifest '$manifestPath' does not contain a scenarios array." + exit 1 + } + + foreach ($scenario in @($manifestScenarios)) { + if (-not (Test-ScenarioTagMatch -ScenarioTags (Get-ObjectPropertyValue -InputObject $scenario -Name "tags") -RequiredTags $Tag)) { + continue + } + + $scenarios += [pscustomobject]@{ + Manifest = $manifest.Path + Id = [string] (Get-ObjectPropertyValue -InputObject $scenario -Name "id") + Policy = [string] (Get-ObjectPropertyValue -InputObject $scenario -Name "policy") + Request = [string] (Get-ObjectPropertyValue -InputObject $scenario -Name "request") + ExpectedDecision = [string] (Get-ObjectPropertyValue -InputObject $scenario -Name "expectedDecision") + ExpectedRuleId = [string] (Get-ObjectPropertyValue -InputObject $scenario -Name "expectedRuleId") + Tags = @((Get-ObjectPropertyValue -InputObject $scenario -Name "tags")) + Description = [string] (Get-ObjectPropertyValue -InputObject $scenario -Name "description") + } + } +} + +if ($scenarios.Count -eq 0) { + Write-Error "No scenarios matched the requested filters." + exit 1 +} + +if ($List) { + $scenarios | + Select-Object Id, @{ Name = "Tags"; Expression = { ($_.Tags -join ",") } }, Policy, Request, ExpectedDecision, ExpectedRuleId, Description | + Format-Table -AutoSize + return +} + +$results = @() +foreach ($scenario in $scenarios) { + if ([string]::IsNullOrWhiteSpace($scenario.Id)) { + Write-Error "A scenario in '$($scenario.Manifest)' is missing an id." + exit 1 + } + if ([string]::IsNullOrWhiteSpace($scenario.Policy)) { + Write-Error "Scenario '$($scenario.Id)' is missing a policy path." + exit 1 + } + if ([string]::IsNullOrWhiteSpace($scenario.Request)) { + Write-Error "Scenario '$($scenario.Id)' is missing a request path." + exit 1 + } + if ($scenario.ExpectedDecision -notin @("allow", "deny")) { + Write-Error "Scenario '$($scenario.Id)' must declare expectedDecision 'allow' or 'deny'." + exit 1 + } + + $policyPath = Resolve-SampleRelativePath -Path $scenario.Policy + $requestPath = Resolve-SampleRelativePath -Path $scenario.Request + $result = Invoke-UniGetUIPolicyFileDecision -PolicyPath $policyPath -RequestPath $requestPath -PolicySchemaPath $policySchemaPath -RequestSchemaPath $requestSchemaPath + $decisionPassed = ($result.Decision -eq $scenario.ExpectedDecision) + $rulePassed = $true + if (-not [string]::IsNullOrWhiteSpace($scenario.ExpectedRuleId)) { + $rulePassed = ($result.RuleId -eq $scenario.ExpectedRuleId) + } + + $results += [pscustomobject]@{ + ScenarioId = $scenario.Id + Tags = ($scenario.Tags -join ",") + Expected = $scenario.ExpectedDecision + Actual = $result.Decision + RuleId = $result.RuleId + ExpectedRuleId = $scenario.ExpectedRuleId + Passed = ($decisionPassed -and $rulePassed) + Reason = $result.Reason + } +} + +$results | Format-Table -AutoSize + +$failures = @($results | Where-Object { -not $_.Passed }) +if ($failures.Count -gt 0) { + Write-Error "$($failures.Count) sample policy simulation scenario(s) failed." + exit 1 +} + +Write-Host "All sample policy simulation scenarios passed." \ No newline at end of file diff --git a/policies/scripts/UniGetUIPolicySimulation.psm1 b/policies/scripts/UniGetUIPolicySimulation.psm1 new file mode 100644 index 0000000000..b80880f799 --- /dev/null +++ b/policies/scripts/UniGetUIPolicySimulation.psm1 @@ -0,0 +1,661 @@ +Set-StrictMode -Version 2.0 + +function ConvertFrom-UniGetUIYamlText { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] $Yaml, + + [Parameter(Mandatory = $true)] + [string] $Path + ) + + $convertFromYamlCommand = Get-Command -Name ConvertFrom-Yaml -ErrorAction SilentlyContinue + if ($null -ne $convertFromYamlCommand) { + try { + return ConvertFrom-Yaml -Yaml $Yaml -Ordered -ErrorAction Stop + } + catch { + throw "Failed to parse YAML file '$Path': $($_.Exception.Message)" + } + } + + $pwshCommand = Get-Command -Name pwsh -ErrorAction SilentlyContinue + if ($null -ne $pwshCommand) { + $script = @' +$yaml = [Console]::In.ReadToEnd() +try { + Import-Module powershell-yaml -ErrorAction Stop + $data = ConvertFrom-Yaml -Yaml $yaml -Ordered -ErrorAction Stop + $data | ConvertTo-Json -Depth 100 +} +catch { + Write-Error $_.Exception.Message + exit 1 +} +'@ + + $output = $Yaml | & $pwshCommand.Source -NoProfile -ExecutionPolicy Bypass -Command $script 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "Failed to parse YAML file '$Path': $($output -join [Environment]::NewLine)" + } + + try { + return ($output -join [Environment]::NewLine) | ConvertFrom-Json -ErrorAction Stop + } + catch { + throw "Failed to convert YAML file '$Path' to canonical JSON: $($_.Exception.Message)" + } + } + + throw "Failed to parse YAML file '$Path': ConvertFrom-Yaml is unavailable. Install the powershell-yaml module or run from a shell that can import it." +} + +function Read-UniGetUIDocumentFile { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] $Path + ) + + $resolvedPath = Resolve-Path -LiteralPath $Path -ErrorAction Stop + $text = Get-Content -LiteralPath $resolvedPath.Path -Raw -Encoding UTF8 + $extension = [System.IO.Path]::GetExtension($resolvedPath.Path).ToLowerInvariant() + $format = $null + $data = $null + $json = $null + + if ($extension -eq ".json") { + $format = "json" + $json = $text + try { + $data = $json | ConvertFrom-Json -ErrorAction Stop + } + catch { + throw "Failed to parse JSON file '$Path': $($_.Exception.Message)" + } + } + elseif ($extension -eq ".yaml" -or $extension -eq ".yml") { + $format = "yaml" + $yamlData = ConvertFrom-UniGetUIYamlText -Yaml $text -Path $Path + try { + $json = $yamlData | ConvertTo-Json -Depth 100 + $data = $json | ConvertFrom-Json -ErrorAction Stop + } + catch { + throw "Failed to normalize YAML file '$Path' to canonical JSON: $($_.Exception.Message)" + } + } + else { + throw "Unsupported policy document extension '$extension' for '$Path'. Use .json, .yaml, or .yml." + } + + [pscustomobject]@{ + Path = $resolvedPath.Path + Format = $format + Text = $text + Json = $json + Data = $data + } +} + +function Read-UniGetUIJsonFile { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] $Path + ) + + Read-UniGetUIDocumentFile -Path $Path +} + +function Test-UniGetUIJsonSchemaIfAvailable { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] $Json, + + [Parameter(Mandatory = $true)] + [string] $SchemaPath + ) + + if (-not (Test-Path -LiteralPath $SchemaPath)) { + return [pscustomobject]@{ + UsedSchema = $false + Passed = $true + Message = "Schema file not found; skipped JSON Schema validation." + } + } + + $testJsonCommand = Get-Command -Name Test-Json -ErrorAction SilentlyContinue + if ($null -eq $testJsonCommand) { + return [pscustomobject]@{ + UsedSchema = $false + Passed = $true + Message = "Test-Json is unavailable; skipped JSON Schema validation." + } + } + + try { + $schema = Get-Content -LiteralPath $SchemaPath -Raw -Encoding UTF8 + $passed = Test-Json -Json $Json -Schema $schema -ErrorAction Stop + return [pscustomobject]@{ + UsedSchema = $true + Passed = [bool] $passed + Message = "JSON Schema validation completed." + } + } + catch { + return [pscustomobject]@{ + UsedSchema = $true + Passed = $false + Message = $_.Exception.Message + } + } +} + +function Assert-UniGetUIPolicyShape { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [pscustomobject] $Policy + ) + + if ($Policy.policyType -ne "packageBrokerPolicy") { + throw "Policy field 'policyType' must be 'packageBrokerPolicy'." + } + if ([string]::IsNullOrWhiteSpace($Policy.policyVersion)) { + throw "Policy field 'policyVersion' is required." + } + if ($null -eq $Policy.metadata -or [string]::IsNullOrWhiteSpace($Policy.metadata.id)) { + throw "Policy field 'metadata.id' is required." + } + if ($null -eq $Policy.enforcement) { + throw "Policy field 'enforcement' is required." + } + if ($Policy.enforcement.failureDecision -ne "deny") { + throw "Policy field 'enforcement.failureDecision' must be 'deny'." + } + if ($Policy.enforcement.defaultDecision -notin @("allow", "deny")) { + throw "Policy field 'enforcement.defaultDecision' must be 'allow' or 'deny'." + } + if ($Policy.enforcement.rulePrecedence -ne "priorityThenDeny") { + throw "Policy field 'enforcement.rulePrecedence' must be 'priorityThenDeny'." + } + if ($null -eq $Policy.rules -or @($Policy.rules).Count -eq 0) { + throw "Policy field 'rules' must contain at least one rule." + } + + foreach ($rule in @($Policy.rules)) { + if ([string]::IsNullOrWhiteSpace($rule.id)) { + throw "Each policy rule requires an 'id'." + } + if ($null -eq $rule.priority) { + throw "Policy rule '$($rule.id)' requires 'priority'." + } + if ($rule.decision -notin @("allow", "deny")) { + throw "Policy rule '$($rule.id)' requires decision 'allow' or 'deny'." + } + if ($null -eq $rule.match) { + throw "Policy rule '$($rule.id)' requires a match object." + } + } +} + +function Assert-UniGetUIRequestShape { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [pscustomobject] $Request + ) + + if ($Request.requestType -ne "packageOperation") { + throw "Request field 'requestType' must be 'packageOperation'." + } + foreach ($requiredField in @("requestVersion", "requestId", "createdAt", "operation")) { + if ([string]::IsNullOrWhiteSpace([string] $Request.$requiredField)) { + throw "Request field '$requiredField' is required." + } + } + if ($Request.operation -notin @("install", "update", "uninstall")) { + throw "Request operation '$($Request.operation)' is not supported." + } + if ($null -eq $Request.manager -or $Request.manager.name -notin @("Winget", "PowerShell")) { + throw "Request manager.name must be 'Winget' or 'PowerShell'." + } + if ($null -eq $Request.source -or [string]::IsNullOrWhiteSpace($Request.source.name)) { + throw "Request source.name is required." + } + if ($null -eq $Request.package -or [string]::IsNullOrWhiteSpace($Request.package.id)) { + throw "Request package.id is required." + } + if ([string]::IsNullOrWhiteSpace($Request.package.name)) { + throw "Request package.name is required." + } + if ($null -eq $Request.options) { + throw "Request options object is required." + } + foreach ($boolField in @("interactive", "skipHashCheck", "preRelease")) { + if ($null -eq $Request.options.$boolField) { + throw "Request options.$boolField is required." + } + } + if ($null -eq $Request.broker -or $Request.broker.requestedElevation -notin @("standard", "elevated")) { + throw "Request broker.requestedElevation must be 'standard' or 'elevated'." + } +} + +function Get-ObjectPropertyValue { + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [object] $InputObject, + + [Parameter(Mandatory = $true)] + [string] $Name + ) + + if ($null -eq $InputObject) { + return $null + } + + $property = $InputObject.PSObject.Properties[$Name] + if ($null -eq $property) { + return $null + } + + $property.Value +} + +function Test-ValueInList { + param( + [object] $Value, + [object] $List + ) + + if ($null -eq $List) { + return $true + } + + foreach ($item in @($List)) { + if ($Value -eq $item) { + return $true + } + } + + $false +} + +function Test-WildcardAny { + param( + [string] $Value, + [object] $Patterns + ) + + if ($null -eq $Patterns) { + return $true + } + + foreach ($pattern in @($Patterns)) { + if ($Value -like [string] $pattern) { + return $true + } + } + + $false +} + +function Get-EffectiveRequestVersion { + param( + [Parameter(Mandatory = $true)] + [pscustomobject] $Request + ) + + if (-not [string]::IsNullOrWhiteSpace([string] (Get-ObjectPropertyValue -InputObject $Request.options -Name "version"))) { + return [string] $Request.options.version + } + if (-not [string]::IsNullOrWhiteSpace([string] (Get-ObjectPropertyValue -InputObject $Request.package -Name "newVersion"))) { + return [string] $Request.package.newVersion + } + if (-not [string]::IsNullOrWhiteSpace([string] (Get-ObjectPropertyValue -InputObject $Request.package -Name "currentVersion"))) { + return [string] $Request.package.currentVersion + } + + "" +} + +function Compare-PackageVersionString { + param( + [Parameter(Mandatory = $true)] + [string] $Left, + + [Parameter(Mandatory = $true)] + [string] $Right + ) + + $leftVersion = $null + $rightVersion = $null + if ([System.Version]::TryParse(($Left -replace "-.+$", ""), [ref] $leftVersion) -and [System.Version]::TryParse(($Right -replace "-.+$", ""), [ref] $rightVersion)) { + return $leftVersion.CompareTo($rightVersion) + } + + [string]::Compare($Left, $Right, $true) +} + +function Test-VersionRangeMatch { + param( + [string] $Version, + [object] $VersionRange + ) + + if ($null -eq $VersionRange) { + return $true + } + if ([string]::IsNullOrWhiteSpace($Version)) { + return $false + } + if (($Version -match "-") -and ($VersionRange.includePrerelease -ne $true)) { + return $false + } + if (-not [string]::IsNullOrWhiteSpace([string] (Get-ObjectPropertyValue -InputObject $VersionRange -Name "minVersion"))) { + if ((Compare-PackageVersionString -Left $Version -Right $VersionRange.minVersion) -lt 0) { + return $false + } + } + if (-not [string]::IsNullOrWhiteSpace([string] (Get-ObjectPropertyValue -InputObject $VersionRange -Name "maxVersion"))) { + if ((Compare-PackageVersionString -Left $Version -Right $VersionRange.maxVersion) -gt 0) { + return $false + } + } + + $true +} + +function Get-RequestDerivedFlags { + param( + [Parameter(Mandatory = $true)] + [pscustomobject] $Request + ) + + $customParameterValue = Get-ObjectPropertyValue -InputObject $Request.options -Name "customParameters" + $customParameters = @($customParameterValue) + if ($null -eq $customParameterValue) { + $customParameters = @() + } + + $killBeforeOperationValue = Get-ObjectPropertyValue -InputObject $Request.options -Name "killBeforeOperation" + $killBeforeOperation = @($killBeforeOperationValue) + if ($null -eq $killBeforeOperationValue) { + $killBeforeOperation = @() + } + + $preCommand = [string] (Get-ObjectPropertyValue -InputObject $Request.options -Name "preOperationCommand") + $postCommand = [string] (Get-ObjectPropertyValue -InputObject $Request.options -Name "postOperationCommand") + $installLocation = [string] (Get-ObjectPropertyValue -InputObject $Request.options -Name "customInstallLocation") + + [pscustomobject]@{ + HasCustomParameters = ($customParameters.Count -gt 0) + HasPrePostCommands = (-not [string]::IsNullOrWhiteSpace($preCommand) -or -not [string]::IsNullOrWhiteSpace($postCommand)) + HasKillBeforeOperation = ($killBeforeOperation.Count -gt 0) + HasCustomInstallLocation = (-not [string]::IsNullOrWhiteSpace($installLocation)) + CustomParameters = $customParameters + KillBeforeOperation = $killBeforeOperation + CustomInstallLocation = $installLocation + } +} + +function Test-UniGetUIRuleMatch { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [pscustomobject] $Rule, + + [Parameter(Mandatory = $true)] + [pscustomobject] $Request + ) + + if ((Get-ObjectPropertyValue -InputObject $Rule -Name "enabled") -eq $false) { + return [pscustomobject]@{ Matched = $false; Reason = "Rule is disabled." } + } + + $ruleMatch = $Rule.match + $flags = Get-RequestDerivedFlags -Request $Request + $effectiveVersion = Get-EffectiveRequestVersion -Request $Request + + $checks = @( + @{ Name = "operations"; Matched = (Test-ValueInList -Value $Request.operation -List (Get-ObjectPropertyValue -InputObject $ruleMatch -Name "operations")) }, + @{ Name = "managers"; Matched = (Test-ValueInList -Value $Request.manager.name -List (Get-ObjectPropertyValue -InputObject $ruleMatch -Name "managers")) }, + @{ Name = "sources"; Matched = (Test-WildcardAny -Value $Request.source.name -Patterns (Get-ObjectPropertyValue -InputObject $ruleMatch -Name "sources")) }, + @{ Name = "packageIdentifiers"; Matched = (Test-WildcardAny -Value $Request.package.id -Patterns (Get-ObjectPropertyValue -InputObject $ruleMatch -Name "packageIdentifiers")) }, + @{ Name = "packageNames"; Matched = (Test-WildcardAny -Value $Request.package.name -Patterns (Get-ObjectPropertyValue -InputObject $ruleMatch -Name "packageNames")) }, + @{ Name = "versions"; Matched = (Test-ValueInList -Value $effectiveVersion -List (Get-ObjectPropertyValue -InputObject $ruleMatch -Name "versions")) }, + @{ Name = "versionRange"; Matched = (Test-VersionRangeMatch -Version $effectiveVersion -VersionRange (Get-ObjectPropertyValue -InputObject $ruleMatch -Name "versionRange")) }, + @{ Name = "scopes"; Matched = (Test-ValueInList -Value (Get-ObjectPropertyValue -InputObject $Request.options -Name "scope") -List (Get-ObjectPropertyValue -InputObject $ruleMatch -Name "scopes")) }, + @{ Name = "architectures"; Matched = (Test-ValueInList -Value (Get-ObjectPropertyValue -InputObject $Request.options -Name "architecture") -List (Get-ObjectPropertyValue -InputObject $ruleMatch -Name "architectures")) }, + @{ Name = "elevation"; Matched = (Test-ValueInList -Value $Request.broker.requestedElevation -List (Get-ObjectPropertyValue -InputObject $ruleMatch -Name "elevation")) }, + @{ Name = "interactive"; Matched = (Test-ValueInList -Value ([bool] $Request.options.interactive) -List (Get-ObjectPropertyValue -InputObject $ruleMatch -Name "interactive")) }, + @{ Name = "skipHashCheck"; Matched = (Test-ValueInList -Value ([bool] $Request.options.skipHashCheck) -List (Get-ObjectPropertyValue -InputObject $ruleMatch -Name "skipHashCheck")) }, + @{ Name = "preRelease"; Matched = (Test-ValueInList -Value ([bool] $Request.options.preRelease) -List (Get-ObjectPropertyValue -InputObject $ruleMatch -Name "preRelease")) }, + @{ Name = "hasCustomParameters"; Matched = (Test-ValueInList -Value ([bool] $flags.HasCustomParameters) -List (Get-ObjectPropertyValue -InputObject $ruleMatch -Name "hasCustomParameters")) }, + @{ Name = "hasCustomInstallLocation"; Matched = (Test-ValueInList -Value ([bool] $flags.HasCustomInstallLocation) -List (Get-ObjectPropertyValue -InputObject $ruleMatch -Name "hasCustomInstallLocation")) }, + @{ Name = "hasPrePostCommands"; Matched = (Test-ValueInList -Value ([bool] $flags.HasPrePostCommands) -List (Get-ObjectPropertyValue -InputObject $ruleMatch -Name "hasPrePostCommands")) }, + @{ Name = "hasKillBeforeOperation"; Matched = (Test-ValueInList -Value ([bool] $flags.HasKillBeforeOperation) -List (Get-ObjectPropertyValue -InputObject $ruleMatch -Name "hasKillBeforeOperation")) } + ) + + foreach ($check in $checks) { + if (-not $check.Matched) { + return [pscustomobject]@{ Matched = $false; Reason = "Selector '$($check.Name)' did not match." } + } + } + + $constraintResult = Test-UniGetUIRuleConstraints -Rule $Rule -Request $Request -Flags $flags + if (-not $constraintResult.Passed) { + return [pscustomobject]@{ Matched = $false; Reason = $constraintResult.Reason } + } + + [pscustomobject]@{ Matched = $true; Reason = "Rule matched." } +} + +function Test-UniGetUIRuleConstraints { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [pscustomobject] $Rule, + + [Parameter(Mandatory = $true)] + [pscustomobject] $Request, + + [Parameter(Mandatory = $true)] + [pscustomobject] $Flags + ) + + $constraints = Get-ObjectPropertyValue -InputObject $Rule -Name "constraints" + if ($null -eq $constraints) { + return [pscustomobject]@{ Passed = $true; Reason = "No constraints." } + } + + $booleanConstraints = @( + @{ Name = "allowInteractive"; IsRisky = [bool] $Request.options.interactive; Description = "interactive installation" }, + @{ Name = "allowSkipHashCheck"; IsRisky = [bool] $Request.options.skipHashCheck; Description = "integrity or publisher bypass" }, + @{ Name = "allowPreRelease"; IsRisky = [bool] $Request.options.preRelease; Description = "prerelease package" }, + @{ Name = "allowCustomInstallLocation"; IsRisky = [bool] $Flags.HasCustomInstallLocation; Description = "custom install location" }, + @{ Name = "allowCustomParameters"; IsRisky = [bool] $Flags.HasCustomParameters; Description = "custom package manager parameters" }, + @{ Name = "allowPrePostCommands"; IsRisky = [bool] $Flags.HasPrePostCommands; Description = "pre or post operation command" }, + @{ Name = "allowKillBeforeOperation"; IsRisky = [bool] $Flags.HasKillBeforeOperation; Description = "kill-before-operation process list" } + ) + + foreach ($constraint in $booleanConstraints) { + $value = Get-ObjectPropertyValue -InputObject $constraints -Name $constraint.Name + if ($value -eq $false -and $constraint.IsRisky) { + return [pscustomobject]@{ Passed = $false; Reason = "Constraint '$($constraint.Name)' denied $($constraint.Description)." } + } + } + + $locationPatterns = Get-ObjectPropertyValue -InputObject $constraints -Name "allowedInstallLocationPatterns" + if ($Flags.HasCustomInstallLocation -and $null -ne $locationPatterns -and -not (Test-WildcardAny -Value $Flags.CustomInstallLocation -Patterns $locationPatterns)) { + return [pscustomobject]@{ Passed = $false; Reason = "Custom install location did not match an allowed pattern." } + } + + $allowedParameters = Get-ObjectPropertyValue -InputObject $constraints -Name "allowedCustomParameters" + $allowedParameterPatterns = Get-ObjectPropertyValue -InputObject $constraints -Name "allowedCustomParameterPatterns" + $deniedParameters = Get-ObjectPropertyValue -InputObject $constraints -Name "deniedCustomParameters" + + foreach ($parameter in @($Flags.CustomParameters)) { + if ($null -ne $deniedParameters -and (Test-WildcardAny -Value $parameter -Patterns $deniedParameters)) { + return [pscustomobject]@{ Passed = $false; Reason = "Custom parameter '$parameter' matched a denied parameter pattern." } + } + + if ($null -ne $allowedParameters -or $null -ne $allowedParameterPatterns) { + $exactAllowed = Test-ValueInList -Value $parameter -List $allowedParameters + $patternAllowed = Test-WildcardAny -Value $parameter -Patterns $allowedParameterPatterns + if (-not ($exactAllowed -or $patternAllowed)) { + return [pscustomobject]@{ Passed = $false; Reason = "Custom parameter '$parameter' was not explicitly allowed." } + } + } + } + + [pscustomobject]@{ Passed = $true; Reason = "Constraints passed." } +} + +function Invoke-UniGetUIPolicyDecision { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [pscustomobject] $Policy, + + [Parameter(Mandatory = $true)] + [pscustomobject] $Request + ) + + Assert-UniGetUIPolicyShape -Policy $Policy + Assert-UniGetUIRequestShape -Request $Request + + $matchingRules = @() + foreach ($rule in @($Policy.rules)) { + $matchResult = Test-UniGetUIRuleMatch -Rule $rule -Request $Request + if ($matchResult.Matched) { + $matchingRules += [pscustomobject]@{ + Id = $rule.id + Priority = [int] $rule.priority + Decision = $rule.decision + Reason = $rule.reason + } + } + } + + if ($matchingRules.Count -eq 0) { + return [pscustomobject]@{ + Decision = $Policy.enforcement.defaultDecision + RuleId = "" + Priority = $null + Reason = "No enabled rule matched; using defaultDecision '$($Policy.enforcement.defaultDecision)'." + MatchedRules = @() + } + } + + $ordered = @($matchingRules | Sort-Object -Property @{ Expression = "Priority"; Ascending = $true }, @{ Expression = { if ($_.Decision -eq "deny") { 0 } else { 1 } }; Ascending = $true }) + $winner = $ordered[0] + + [pscustomobject]@{ + Decision = $winner.Decision + RuleId = $winner.Id + Priority = $winner.Priority + Reason = $winner.Reason + MatchedRules = $ordered + } +} + +function Invoke-UniGetUIPolicyFileDecision { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] $PolicyPath, + + [Parameter(Mandatory = $true)] + [string] $RequestPath, + + [string] $PolicySchemaPath, + + [string] $RequestSchemaPath + ) + + $policyFile = Read-UniGetUIDocumentFile -Path $PolicyPath + $requestFile = Read-UniGetUIDocumentFile -Path $RequestPath + + $policySchemaResult = $null + $requestSchemaResult = $null + if (-not [string]::IsNullOrWhiteSpace($PolicySchemaPath)) { + $policySchemaResult = Test-UniGetUIJsonSchemaIfAvailable -Json $policyFile.Json -SchemaPath $PolicySchemaPath + } + if (-not [string]::IsNullOrWhiteSpace($RequestSchemaPath)) { + $requestSchemaResult = Test-UniGetUIJsonSchemaIfAvailable -Json $requestFile.Json -SchemaPath $RequestSchemaPath + } + + if ($null -ne $policySchemaResult -and -not $policySchemaResult.Passed) { + return [pscustomobject]@{ + PolicyPath = $policyFile.Path + RequestPath = $requestFile.Path + RequestId = $requestFile.Data.requestId + Decision = "deny" + RuleId = "" + Priority = $null + Reason = "Policy schema validation failed: $($policySchemaResult.Message)" + SchemaValidation = [pscustomobject]@{ Policy = $policySchemaResult; Request = $requestSchemaResult } + } + } + if ($null -ne $requestSchemaResult -and -not $requestSchemaResult.Passed) { + return [pscustomobject]@{ + PolicyPath = $policyFile.Path + RequestPath = $requestFile.Path + RequestId = $requestFile.Data.requestId + Decision = "deny" + RuleId = "" + Priority = $null + Reason = "Request schema validation failed: $($requestSchemaResult.Message)" + SchemaValidation = [pscustomobject]@{ Policy = $policySchemaResult; Request = $requestSchemaResult } + } + } + + try { + $decision = Invoke-UniGetUIPolicyDecision -Policy $policyFile.Data -Request $requestFile.Data + + [pscustomobject]@{ + PolicyPath = $policyFile.Path + RequestPath = $requestFile.Path + RequestId = $requestFile.Data.requestId + Manager = $requestFile.Data.manager.name + Source = $requestFile.Data.source.name + PackageId = $requestFile.Data.package.id + Operation = $requestFile.Data.operation + Decision = $decision.Decision + RuleId = $decision.RuleId + Priority = $decision.Priority + Reason = $decision.Reason + MatchedRules = $decision.MatchedRules + SchemaValidation = [pscustomobject]@{ Policy = $policySchemaResult; Request = $requestSchemaResult } + } + } + catch { + [pscustomobject]@{ + PolicyPath = $policyFile.Path + RequestPath = $requestFile.Path + RequestId = $requestFile.Data.requestId + Decision = "deny" + RuleId = "" + Priority = $null + Reason = "Semantic validation failed: $($_.Exception.Message)" + SchemaValidation = [pscustomobject]@{ Policy = $policySchemaResult; Request = $requestSchemaResult } + } + } +} + +Export-ModuleMember -Function @( + "Read-UniGetUIDocumentFile", + "Read-UniGetUIJsonFile", + "Test-UniGetUIJsonSchemaIfAvailable", + "Get-ObjectPropertyValue", + "Assert-UniGetUIPolicyShape", + "Assert-UniGetUIRequestShape", + "Invoke-UniGetUIPolicyDecision", + "Invoke-UniGetUIPolicyFileDecision" +) \ No newline at end of file diff --git a/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs b/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs index 601c1865f6..e04209e349 100644 --- a/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs +++ b/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs @@ -92,6 +92,9 @@ public enum K DisableClassicMode, DisableInstallerHostChangeWarning, BunPreferLatestVersions, + // NOTE: Set this to true to delegate package operations to Devolutions Agent broker + // instead of using local UAC elevation. Change default here when ready for production. + UseAgentBroker, Test1, Test2, @@ -195,6 +198,7 @@ public static string ResolveKey(K key) K.DisableClassicMode => "DisableClassicMode", K.DisableInstallerHostChangeWarning => "DisableInstallerHostChangeWarning", K.BunPreferLatestVersions => "BunPreferLatestVersions", + K.UseAgentBroker => "UseAgentBroker", K.Test1 => "TestSetting1", K.Test2 => "TestSetting2", diff --git a/src/UniGetUI.PackageEngine.AgentBroker.Tests/BrokerModelDeserializationTests.cs b/src/UniGetUI.PackageEngine.AgentBroker.Tests/BrokerModelDeserializationTests.cs new file mode 100644 index 0000000000..02775ffe2e --- /dev/null +++ b/src/UniGetUI.PackageEngine.AgentBroker.Tests/BrokerModelDeserializationTests.cs @@ -0,0 +1,153 @@ +using System.Text.Json; +using UniGetUI.PackageEngine.AgentBroker; + +namespace UniGetUI.PackageEngine.AgentBroker.Tests; + +/// +/// Verifies that C# broker models can deserialize all Rust-generated sample files +/// without data loss. This ensures wire compatibility with the Rust broker implementation. +/// +public class BrokerModelDeserializationTests +{ + private static string SamplesRoot => + Path.Combine(AppContext.BaseDirectory, "samples"); + + [Theory] + [MemberData(nameof(GetRequestFiles))] + public void DeserializeRequest_RustSample(string fileName) + { + var path = Path.Combine(SamplesRoot, "requests", fileName); + var json = File.ReadAllText(path); + var request = JsonSerializer.Deserialize(json, BrokerJsonContext.Default.BrokerRequest); + + Assert.NotNull(request); + Assert.False(string.IsNullOrEmpty(request.RequestId), $"requestId must not be empty in {fileName}"); + Assert.False(string.IsNullOrEmpty(request.Operation), $"operation must not be empty in {fileName}"); + } + + [Theory] + [MemberData(nameof(GetStatusRequestFiles))] + public void DeserializeStatusRequest_RustSample(string fileName) + { + var path = Path.Combine(SamplesRoot, "requests", fileName); + var json = File.ReadAllText(path); + var request = JsonSerializer.Deserialize(json, BrokerJsonContext.Default.BrokerStatusRequest); + + Assert.NotNull(request); + Assert.False(string.IsNullOrEmpty(request.RequestId), $"requestId must not be empty in {fileName}"); + } + + [Theory] + [MemberData(nameof(GetAllowedResponseFiles))] + public void DeserializeResponse_RustSample(string fileName) + { + var path = Path.Combine(SamplesRoot, "responses", fileName); + var json = File.ReadAllText(path); + var response = JsonSerializer.Deserialize(json, BrokerJsonContext.Default.BrokerResponse); + + Assert.NotNull(response); + Assert.False(string.IsNullOrEmpty(response.RequestId), $"requestId must not be empty in {fileName}"); + Assert.False(string.IsNullOrEmpty(response.Decision), $"decision must not be empty in {fileName}"); + } + + [Theory] + [MemberData(nameof(GetStatusResponseFiles))] + public void DeserializeStatusResponse_RustSample(string fileName) + { + var path = Path.Combine(SamplesRoot, "responses", fileName); + var json = File.ReadAllText(path); + var response = JsonSerializer.Deserialize(json, BrokerJsonContext.Default.BrokerStatusResponse); + + Assert.NotNull(response); + Assert.False(string.IsNullOrEmpty(response.RequestId), $"requestId must not be empty in {fileName}"); + Assert.False(string.IsNullOrEmpty(response.Status), $"status must not be empty in {fileName}"); + } + + [Fact] + public void SerializeRequest_RoundTrip_PreservesFields() + { + var path = Path.Combine(SamplesRoot, "requests", "winget-vscode-install.request.json"); + var json = File.ReadAllText(path); + var request = JsonSerializer.Deserialize(json, BrokerJsonContext.Default.BrokerRequest)!; + + // Verify key fields survived deserialization + Assert.Equal("req-winget-vscode-install", request.RequestId); + Assert.Equal("install", request.Operation); + Assert.Equal("Winget", request.Manager.Name); + Assert.Equal("winget", request.Source.Name); + Assert.Equal("Microsoft.VisualStudioCode", request.Package.Id); + Assert.Equal("x64", request.Package.Architecture); + Assert.Equal("machine", request.Options.Scope); + Assert.Equal("elevated", request.Broker.RequestedElevation); + Assert.Equal("CONTOSO\\alice", request.Broker.EffectiveUser); + + // Round-trip: serialize back and re-parse + var reserialized = JsonSerializer.Serialize(request, BrokerJsonContext.Default.BrokerRequest); + var reparsed = JsonSerializer.Deserialize(reserialized, BrokerJsonContext.Default.BrokerRequest)!; + Assert.Equal(request.RequestId, reparsed.RequestId); + Assert.Equal(request.Package.Architecture, reparsed.Package.Architecture); + } + + [Fact] + public void SerializeStatusResponse_RoundTrip_PreservesFields() + { + var path = Path.Combine(SamplesRoot, "responses", "status-completed.response.json"); + var json = File.ReadAllText(path); + var response = JsonSerializer.Deserialize(json, BrokerJsonContext.Default.BrokerStatusResponse)!; + + Assert.Equal("req-winget-vscode-install", response.RequestId); + Assert.Equal("completed", response.Status); + Assert.Equal(0, response.ExitCode); + Assert.Equal("Process exited successfully.", response.Note); + } + + public static TheoryData GetRequestFiles() + { + var data = new TheoryData(); + var dir = Path.Combine(AppContext.BaseDirectory, "samples", "requests"); + foreach (var file in Directory.EnumerateFiles(dir, "*.request.json")) + { + var name = Path.GetFileName(file); + // Skip status requests — they use a different schema + if (!name.Contains("status")) + data.Add(name); + } + return data; + } + + public static TheoryData GetStatusRequestFiles() + { + var data = new TheoryData(); + var dir = Path.Combine(AppContext.BaseDirectory, "samples", "requests"); + foreach (var file in Directory.EnumerateFiles(dir, "*status*.request.json")) + { + data.Add(Path.GetFileName(file)); + } + return data; + } + + public static TheoryData GetAllowedResponseFiles() + { + var data = new TheoryData(); + var dir = Path.Combine(AppContext.BaseDirectory, "samples", "responses"); + foreach (var file in Directory.EnumerateFiles(dir, "*.response.json")) + { + var name = Path.GetFileName(file); + // Only non-status responses use BrokerResponse + if (!name.StartsWith("status-")) + data.Add(name); + } + return data; + } + + public static TheoryData GetStatusResponseFiles() + { + var data = new TheoryData(); + var dir = Path.Combine(AppContext.BaseDirectory, "samples", "responses"); + foreach (var file in Directory.EnumerateFiles(dir, "status-*.response.json")) + { + data.Add(Path.GetFileName(file)); + } + return data; + } +} diff --git a/src/UniGetUI.PackageEngine.AgentBroker.Tests/UniGetUI.PackageEngine.AgentBroker.Tests.csproj b/src/UniGetUI.PackageEngine.AgentBroker.Tests/UniGetUI.PackageEngine.AgentBroker.Tests.csproj new file mode 100644 index 0000000000..a5a3d15223 --- /dev/null +++ b/src/UniGetUI.PackageEngine.AgentBroker.Tests/UniGetUI.PackageEngine.AgentBroker.Tests.csproj @@ -0,0 +1,31 @@ + + + $(PortableTargetFramework) + + false + true + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + diff --git a/src/UniGetUI.PackageEngine.AgentBroker/BrokerClient.cs b/src/UniGetUI.PackageEngine.AgentBroker/BrokerClient.cs new file mode 100644 index 0000000000..db08d7d354 --- /dev/null +++ b/src/UniGetUI.PackageEngine.AgentBroker/BrokerClient.cs @@ -0,0 +1,389 @@ +using System.IO.Pipes; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using UniGetUI.Core.Logging; + +namespace UniGetUI.PackageEngine.AgentBroker; + +/// +/// Client for communicating with the Devolutions Agent UniGetUI Package Broker +/// over a Windows named pipe using HTTP/1.1 wire protocol. +/// +public sealed class BrokerClient : IDisposable +{ + private const string DEFAULT_PIPE_NAME = "UniGetUI.PackageBroker.v1"; + private const string PROTOCOL_VERSION = "1.0"; + private const string REQUEST_MEDIA_TYPE = "application/vnd.unigetui.package-request+json; version=1.0"; + private const string RESPONSE_MEDIA_TYPE = "application/vnd.unigetui.package-broker-response+json; version=1.0"; + private const int CONNECT_TIMEOUT_MS = 5000; + private const int READ_TIMEOUT_MS = 30000; + + private readonly string _pipeName; + + public BrokerClient(string? pipeName = null) + { + _pipeName = pipeName ?? DEFAULT_PIPE_NAME; + } + + /// + /// Check if the broker service is available (pipe exists and responds to health check). + /// + public async Task IsAvailableAsync() + { +#if !WINDOWS + return false; +#else + try + { + Logger.Debug($"[BrokerClient] Checking availability on pipe '{_pipeName}'..."); + var response = await SendHttpRequestAsync("GET", "/v1/health", null); + Logger.Debug($"[BrokerClient] Health check: status={response.StatusCode}, body={response.Body}"); + return response.StatusCode == 200; + } + catch (Exception ex) + { + Logger.Debug($"[BrokerClient] Broker not available: {ex.GetType().Name}: {ex.Message}"); + return false; + } +#endif + } + + /// + /// Send a package operation request to the broker for evaluation only (dry-run). + /// + public async Task EvaluateAsync(BrokerRequest request) + { +#if !WINDOWS + return null; +#else + return await SendPackageOperationAsync(request, "/v1/package-operations/evaluate"); +#endif + } + + /// + /// Send a package operation request to the broker for execution. + /// Returns the initial broker response (policy evaluation + execution acceptance). + /// + public async Task ExecuteAsync(BrokerRequest request) + { +#if !WINDOWS + return null; +#else + return await SendPackageOperationAsync(request, "/v1/package-operations"); +#endif + } + + /// + /// Send a package operation and poll for completion status. + /// Returns the final status response once the operation finishes (completed/failed) + /// or null if the initial request fails. + /// + public async Task ExecuteAndWaitAsync( + BrokerRequest request, + CancellationToken cancellationToken = default, + int pollIntervalMs = 500) + { +#if !WINDOWS + return null; +#else + // Step 1: Send the execute request. + var executeResponse = await SendPackageOperationAsync(request, "/v1/package-operations"); + if (executeResponse is null) + { + Logger.Error("[BrokerClient] Execute request failed, cannot poll for status."); + return null; + } + + if (executeResponse.Decision != "allow") + { + Logger.Debug($"[BrokerClient] Operation denied by policy: {executeResponse.Reason}"); + return new BrokerStatusResponse + { + RequestId = request.RequestId, + Status = "failed", + Note = $"Denied by policy: {executeResponse.Reason}", + }; + } + + // Step 2: Poll for status until terminal state. + Logger.Debug($"[BrokerClient] Operation submitted, polling status for requestId={request.RequestId}"); + + while (!cancellationToken.IsCancellationRequested) + { + await Task.Delay(pollIntervalMs, cancellationToken); + + var status = await QueryStatusAsync(request.RequestId, request.Broker); + if (status is null) + { + Logger.Warn("[BrokerClient] Status query returned null, retrying..."); + continue; + } + + Logger.Debug($"[BrokerClient] Poll: status={status.Status}, exitCode={status.ExitCode}"); + + if (status.Status is "completed" or "failed") + { + return status; + } + } + + // Cancelled. + return new BrokerStatusResponse + { + RequestId = request.RequestId, + Status = "failed", + Note = "Operation polling was cancelled.", + }; +#endif + } + + /// + /// Query the status of a previously submitted package operation. + /// + public async Task QueryStatusAsync(string requestId, BrokerRequestContext brokerContext) + { +#if !WINDOWS + return null; +#else + try + { + var statusRequest = new BrokerStatusRequest + { + RequestId = requestId, + Broker = brokerContext, + }; + + var body = JsonSerializer.Serialize(statusRequest, BrokerJsonContext.Default.BrokerStatusRequest); + + Logger.Debug($"[BrokerClient] Sending POST /v1/package-operations/status (body length={body.Length})"); + + var headers = new Dictionary + { + ["Content-Type"] = "application/json", + ["Accept"] = "application/json", + ["UniGetUI-Protocol-Version"] = PROTOCOL_VERSION, + ["Host"] = "unigetui-broker" + }; + + var response = await SendHttpRequestAsync("POST", "/v1/package-operations/status", body, headers); + + Logger.Debug($"[BrokerClient] Status response: status={response.StatusCode}, body length={response.Body?.Length ?? 0}"); + + if (string.IsNullOrWhiteSpace(response.Body)) + { + Logger.Error($"[BrokerClient] Empty status response body (status: {response.StatusCode})"); + return null; + } + + var statusResponse = JsonSerializer.Deserialize(response.Body, BrokerJsonContext.Default.BrokerStatusResponse); + Logger.Debug($"[BrokerClient] Status: {statusResponse?.Status}, exitCode={statusResponse?.ExitCode}"); + return statusResponse; + } + catch (Exception ex) + { + Logger.Error($"[BrokerClient] Error querying operation status: {ex.Message}"); + Logger.Error(ex); + return null; + } +#endif + } + + public void Dispose() + { + // No persistent resources to dispose. + } + +#if WINDOWS + private async Task SendPackageOperationAsync(BrokerRequest request, string endpoint) + { + try + { + var body = JsonSerializer.Serialize(request, BrokerJsonContext.Default.BrokerRequest); + + Logger.Debug($"[BrokerClient] Sending POST {endpoint} (body length={body.Length})"); + Logger.Debug($"[BrokerClient] Request body: {body}"); + + var headers = new Dictionary + { + ["Content-Type"] = REQUEST_MEDIA_TYPE, + ["Accept"] = RESPONSE_MEDIA_TYPE, + ["UniGetUI-Protocol-Version"] = PROTOCOL_VERSION, + ["UniGetUI-Request-Id"] = request.RequestId, + ["Host"] = "unigetui-broker" + }; + + var response = await SendHttpRequestAsync("POST", endpoint, body, headers); + + Logger.Debug($"[BrokerClient] Response status={response.StatusCode}, body length={response.Body?.Length ?? 0}"); + Logger.Debug($"[BrokerClient] Response body: {response.Body}"); + + if (string.IsNullOrWhiteSpace(response.Body)) + { + Logger.Error($"[BrokerClient] Empty response body from broker (status: {response.StatusCode})"); + return null; + } + + var brokerResponse = JsonSerializer.Deserialize(response.Body, BrokerJsonContext.Default.BrokerResponse); + Logger.Debug($"[BrokerClient] Deserialized: decision={brokerResponse?.Decision}, reason={brokerResponse?.Reason}"); + return brokerResponse; + } + catch (Exception ex) + { + Logger.Error($"[BrokerClient] Error communicating with broker: {ex.Message}"); + Logger.Error(ex); + return null; + } + } + + /// + /// Send a raw HTTP/1.1 request over the named pipe and read the response. + /// + private async Task SendHttpRequestAsync( + string method, + string path, + string? body, + Dictionary? extraHeaders = null) + { + using var pipe = new NamedPipeClientStream(".", _pipeName, PipeDirection.InOut, PipeOptions.Asynchronous); + + Logger.Debug($"[BrokerClient] Connecting to pipe '{_pipeName}'..."); + var connectCts = new CancellationTokenSource(CONNECT_TIMEOUT_MS); + await pipe.ConnectAsync(connectCts.Token); + Logger.Debug($"[BrokerClient] Connected! IsConnected={pipe.IsConnected}, CanRead={pipe.CanRead}, CanWrite={pipe.CanWrite}"); + + // Build HTTP/1.1 request. + var requestBuilder = new StringBuilder(); + requestBuilder.Append($"{method} {path} HTTP/1.1\r\n"); + requestBuilder.Append("Host: unigetui-broker\r\n"); + requestBuilder.Append("Connection: close\r\n"); + + if (extraHeaders != null) + { + foreach (var (key, value) in extraHeaders) + { + if (!key.Equals("Host", StringComparison.OrdinalIgnoreCase)) + { + requestBuilder.Append($"{key}: {value}\r\n"); + } + } + } + + byte[]? bodyBytes = null; + if (body != null) + { + bodyBytes = Encoding.UTF8.GetBytes(body); + requestBuilder.Append($"Content-Length: {bodyBytes.Length}\r\n"); + } + else + { + requestBuilder.Append("Content-Length: 0\r\n"); + } + + requestBuilder.Append("\r\n"); + + // Write request. + var headerBytes = Encoding.ASCII.GetBytes(requestBuilder.ToString()); + await pipe.WriteAsync(headerBytes); + if (bodyBytes != null) + { + await pipe.WriteAsync(bodyBytes); + } + await pipe.FlushAsync(); + Logger.Debug($"[BrokerClient] Wrote {headerBytes.Length + (bodyBytes?.Length ?? 0)} bytes to pipe, reading response..."); + + // Read response. + var readCts = new CancellationTokenSource(READ_TIMEOUT_MS); + return await ReadHttpResponseAsync(pipe, readCts.Token); + } + + /// + /// Parse an HTTP/1.1 response from the pipe stream. + /// + private static async Task ReadHttpResponseAsync(Stream stream, CancellationToken ct) + { + var buffer = new byte[65536]; + var totalRead = 0; + + // Read until we have at least the headers. + while (totalRead < buffer.Length) + { + var bytesRead = await stream.ReadAsync(buffer.AsMemory(totalRead, buffer.Length - totalRead), ct); + Logger.Debug($"[BrokerClient] Pipe read: {bytesRead} bytes (total so far: {totalRead + bytesRead})"); + if (bytesRead == 0) + { + Logger.Debug($"[BrokerClient] Pipe returned 0 bytes (closed). Total read: {totalRead}"); + if (totalRead > 0) + { + Logger.Debug($"[BrokerClient] Raw data so far: {Encoding.UTF8.GetString(buffer, 0, Math.Min(totalRead, 500))}"); + } + break; + } + totalRead += bytesRead; + + // Check if we have the end of headers. + var currentText = Encoding.ASCII.GetString(buffer, 0, totalRead); + var headerEnd = currentText.IndexOf("\r\n\r\n", StringComparison.Ordinal); + if (headerEnd >= 0) + { + var headerText = currentText[..headerEnd]; + var bodyStart = headerEnd + 4; + + // Parse status line. + var lines = headerText.Split("\r\n"); + var statusLine = lines[0]; + var statusCode = int.Parse(statusLine.Split(' ')[1]); + + // Parse headers. + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (int i = 1; i < lines.Length; i++) + { + var colonIdx = lines[i].IndexOf(':'); + if (colonIdx > 0) + { + var key = lines[i][..colonIdx].Trim(); + var value = lines[i][(colonIdx + 1)..].Trim(); + headers[key] = value; + } + } + + // Read body based on Content-Length. + int contentLength = 0; + if (headers.TryGetValue("Content-Length", out var clStr)) + { + contentLength = int.Parse(clStr); + } + + var bodyBytesRead = totalRead - bodyStart; + while (bodyBytesRead < contentLength) + { + var remaining = contentLength - bodyBytesRead; + if (bodyStart + bodyBytesRead + remaining > buffer.Length) + { + // Grow buffer if needed. + var newBuffer = new byte[bodyStart + contentLength]; + Buffer.BlockCopy(buffer, 0, newBuffer, 0, totalRead); + buffer = newBuffer; + } + + var read = await stream.ReadAsync( + buffer.AsMemory(bodyStart + bodyBytesRead, remaining), ct); + if (read == 0) break; + bodyBytesRead += read; + totalRead += read; + } + + var bodyText = Encoding.UTF8.GetString(buffer, bodyStart, contentLength); + return new HttpPipeResponse(statusCode, headers, bodyText); + } + } + + throw new InvalidOperationException("Failed to read complete HTTP response from pipe"); + } + + private readonly record struct HttpPipeResponse( + int StatusCode, + Dictionary Headers, + string Body); +#endif +} diff --git a/src/UniGetUI.PackageEngine.AgentBroker/BrokerModels.cs b/src/UniGetUI.PackageEngine.AgentBroker/BrokerModels.cs new file mode 100644 index 0000000000..0f053351ae --- /dev/null +++ b/src/UniGetUI.PackageEngine.AgentBroker/BrokerModels.cs @@ -0,0 +1,322 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace UniGetUI.PackageEngine.AgentBroker; + +// ═══════════════════════════════════════════════════════════════════════════════ +// Request models — matches Rust PackageRequest (deny_unknown_fields) +// ═══════════════════════════════════════════════════════════════════════════════ + +/// +/// Package operation request matching the broker protocol v1.0. +/// +public sealed class BrokerRequest +{ + [JsonPropertyName("$schema")] + public string Schema { get; set; } = "https://aka.ms/unigetui/package-request.schema.1.0.json"; + + [JsonPropertyName("requestVersion")] + public string RequestVersion { get; set; } = "1.0.0"; + + [JsonPropertyName("requestType")] + public string RequestType { get; set; } = "packageOperation"; + + [JsonPropertyName("requestId")] + public string RequestId { get; set; } = ""; + + [JsonPropertyName("createdAt")] + public string CreatedAt { get; set; } = ""; + + [JsonPropertyName("operation")] + public string Operation { get; set; } = ""; + + [JsonPropertyName("manager")] + public BrokerRequestManager Manager { get; set; } = new(); + + [JsonPropertyName("source")] + public BrokerRequestSource Source { get; set; } = new(); + + [JsonPropertyName("package")] + public BrokerRequestPackage Package { get; set; } = new(); + + [JsonPropertyName("options")] + public BrokerRequestOptions Options { get; set; } = new(); + + [JsonPropertyName("broker")] + public BrokerRequestContext Broker { get; set; } = new(); +} + +public sealed class BrokerRequestManager +{ + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = ""; + + [JsonPropertyName("executableFriendlyName")] + public string ExecutableFriendlyName { get; set; } = ""; +} + +public sealed class BrokerRequestSource +{ + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("url")] + public string? Url { get; set; } + + [JsonPropertyName("isVirtualManager")] + public bool? IsVirtualManager { get; set; } +} + +/// +/// Package identification — architecture and version live HERE (not in Options). +/// +public sealed class BrokerRequestPackage +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("version")] + public string? Version { get; set; } + + [JsonPropertyName("architecture")] + public string? Architecture { get; set; } + + [JsonPropertyName("channel")] + public string? Channel { get; set; } +} + +/// +/// Operation options — NO architecture/version here (those are on Package). +/// +public sealed class BrokerRequestOptions +{ + [JsonPropertyName("scope")] + public string? Scope { get; set; } + + [JsonPropertyName("interactive")] + public bool Interactive { get; set; } + + [JsonPropertyName("skipHashCheck")] + public bool SkipHashCheck { get; set; } + + [JsonPropertyName("preRelease")] + public bool PreRelease { get; set; } + + [JsonPropertyName("uninstallPrevious")] + public bool UninstallPrevious { get; set; } + + [JsonPropertyName("noUpgrade")] + public bool NoUpgrade { get; set; } + + [JsonPropertyName("customParameters")] + public List CustomParameters { get; set; } = []; + + [JsonPropertyName("customInstallLocation")] + public string? CustomInstallLocation { get; set; } + + [JsonPropertyName("killBeforeOperation")] + public List KillBeforeOperation { get; set; } = []; + + [JsonPropertyName("preOperationCommand")] + public string? PreOperationCommand { get; set; } + + [JsonPropertyName("postOperationCommand")] + public string? PostOperationCommand { get; set; } +} + +public sealed class BrokerRequestContext +{ + [JsonPropertyName("requestedElevation")] + public string RequestedElevation { get; set; } = "elevated"; + + [JsonPropertyName("effectiveUser")] + public string EffectiveUser { get; set; } = ""; + + [JsonPropertyName("clientVersion")] + public string? ClientVersion { get; set; } + + [JsonPropertyName("clientProcessPath")] + public string? ClientProcessPath { get; set; } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Response models — matches Rust BrokerResponse +// ═══════════════════════════════════════════════════════════════════════════════ + +/// +/// Broker evaluation/execution response. +/// +public sealed class BrokerResponse +{ + [JsonPropertyName("$schema")] + public string? Schema { get; set; } + + [JsonPropertyName("responseVersion")] + public string ResponseVersion { get; set; } = ""; + + [JsonPropertyName("responseType")] + public string ResponseType { get; set; } = ""; + + [JsonPropertyName("broker")] + public BrokerResponseInfo? Broker { get; set; } + + [JsonPropertyName("auditId")] + public string AuditId { get; set; } = ""; + + [JsonPropertyName("requestId")] + public string RequestId { get; set; } = ""; + + [JsonPropertyName("receivedAt")] + public string ReceivedAt { get; set; } = ""; + + [JsonPropertyName("completedAt")] + public string CompletedAt { get; set; } = ""; + + [JsonPropertyName("manager")] + public string? Manager { get; set; } + + [JsonPropertyName("source")] + public string? Source { get; set; } + + [JsonPropertyName("packageId")] + public string? PackageId { get; set; } + + [JsonPropertyName("operation")] + public string? Operation { get; set; } + + [JsonPropertyName("decision")] + public string Decision { get; set; } = ""; + + [JsonPropertyName("ruleId")] + public string RuleId { get; set; } = ""; + + [JsonPropertyName("reason")] + public string Reason { get; set; } = ""; + + [JsonPropertyName("wouldExecute")] + public bool WouldExecute { get; set; } + + [JsonPropertyName("policy")] + public BrokerPolicyInfo? Policy { get; set; } + + [JsonPropertyName("execution")] + public BrokerExecutionInfo? Execution { get; set; } +} + +public sealed class BrokerResponseInfo +{ + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("protocolVersion")] + public string ProtocolVersion { get; set; } = ""; + + [JsonPropertyName("transport")] + public string Transport { get; set; } = ""; + + [JsonPropertyName("pipeName")] + public string? PipeName { get; set; } + + [JsonPropertyName("elevatedSimulation")] + public bool ElevatedSimulation { get; set; } +} + +public sealed class BrokerPolicyInfo +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("revision")] + public int Revision { get; set; } + + [JsonPropertyName("policyVersion")] + public string PolicyVersion { get; set; } = ""; +} + +public sealed class BrokerExecutionInfo +{ + [JsonPropertyName("mode")] + public string Mode { get; set; } = ""; + + [JsonPropertyName("command")] + public List Command { get; set; } = []; + + [JsonPropertyName("note")] + public string Note { get; set; } = ""; +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Status query models — matches Rust StatusRequest / StatusResponse +// ═══════════════════════════════════════════════════════════════════════════════ + +/// +/// Status query request for a previously submitted package operation. +/// +public sealed class BrokerStatusRequest +{ + [JsonPropertyName("$schema")] + public string Schema { get; set; } = "https://aka.ms/unigetui/package-operation-status-request.schema.1.0.json"; + + [JsonPropertyName("requestVersion")] + public string RequestVersion { get; set; } = "1.0.0"; + + [JsonPropertyName("requestType")] + public string RequestType { get; set; } = "packageOperationStatus"; + + [JsonPropertyName("requestId")] + public string RequestId { get; set; } = ""; + + [JsonPropertyName("broker")] + public BrokerRequestContext Broker { get; set; } = new(); +} + +/// +/// Status query response from the broker. +/// +public sealed class BrokerStatusResponse +{ + [JsonPropertyName("$schema")] + public string? Schema { get; set; } + + [JsonPropertyName("responseVersion")] + public string ResponseVersion { get; set; } = ""; + + [JsonPropertyName("responseType")] + public string ResponseType { get; set; } = ""; + + [JsonPropertyName("broker")] + public BrokerResponseInfo? Broker { get; set; } + + [JsonPropertyName("requestId")] + public string RequestId { get; set; } = ""; + + [JsonPropertyName("status")] + public string Status { get; set; } = ""; + + [JsonPropertyName("startedAt")] + public string? StartedAt { get; set; } + + [JsonPropertyName("completedAt")] + public string? CompletedAt { get; set; } + + [JsonPropertyName("exitCode")] + public int? ExitCode { get; set; } + + [JsonPropertyName("note")] + public string? Note { get; set; } +} + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(BrokerRequest))] +[JsonSerializable(typeof(BrokerResponse))] +[JsonSerializable(typeof(BrokerStatusRequest))] +[JsonSerializable(typeof(BrokerStatusResponse))] +public sealed partial class BrokerJsonContext : JsonSerializerContext; diff --git a/src/UniGetUI.PackageEngine.AgentBroker/BrokerRequestBuilder.cs b/src/UniGetUI.PackageEngine.AgentBroker/BrokerRequestBuilder.cs new file mode 100644 index 0000000000..e1007ae3c0 --- /dev/null +++ b/src/UniGetUI.PackageEngine.AgentBroker/BrokerRequestBuilder.cs @@ -0,0 +1,133 @@ +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.Serializable; + +namespace UniGetUI.PackageEngine.AgentBroker; + +/// +/// Builds broker protocol requests from UniGetUI domain objects. +/// Maps IPackage + InstallOptions + OperationType into the canonical +/// package operation request format expected by the Devolutions Agent broker. +/// +public static class BrokerRequestBuilder +{ + private static readonly string ClientVersion = + System.Reflection.Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "0.0.0"; + + /// + /// Build a broker request from UniGetUI package operation parameters. + /// + public static BrokerRequest Build(IPackage package, InstallOptions options, OperationType role) + { + var request = new BrokerRequest + { + RequestId = $"req-{Guid.NewGuid():N}", + CreatedAt = DateTimeOffset.UtcNow.ToString("o"), + Operation = MapOperation(role), + Manager = new BrokerRequestManager + { + Name = MapManagerName(package.Manager.Name), + DisplayName = package.Manager.DisplayName, + ExecutableFriendlyName = Path.GetFileName(package.Manager.Status.ExecutablePath) + }, + Source = new BrokerRequestSource + { + Name = package.Source.Name, + Url = package.Source.Url?.ToString(), + IsVirtualManager = false + }, + Package = new BrokerRequestPackage + { + Id = package.Id, + Name = package.Name, + Version = string.IsNullOrEmpty(options.Version) ? null : options.Version, + Architecture = string.IsNullOrEmpty(options.Architecture) ? null : options.Architecture.ToLowerInvariant(), + }, + Options = new BrokerRequestOptions + { + Scope = MapScope(options.InstallationScope), + Interactive = options.InteractiveInstallation, + SkipHashCheck = options.SkipHashCheck, + PreRelease = options.PreRelease, + CustomParameters = GetCustomParameters(options, role), + CustomInstallLocation = string.IsNullOrEmpty(options.CustomInstallLocation) ? null : options.CustomInstallLocation, + KillBeforeOperation = options.KillBeforeOperation ?? [], + PreOperationCommand = GetPreCommand(options, role), + PostOperationCommand = GetPostCommand(options, role) + }, + Broker = new BrokerRequestContext + { + RequestedElevation = options.RunAsAdministrator ? "elevated" : "standard", + EffectiveUser = $"{Environment.UserDomainName}\\{Environment.UserName}", + ClientVersion = ClientVersion, + ClientProcessPath = Environment.ProcessPath + } + }; + + return request; + } + + private static string MapOperation(OperationType role) => role switch + { + OperationType.Install => "install", + OperationType.Update => "update", + OperationType.Uninstall => "uninstall", + _ => throw new ArgumentException($"Unsupported operation type: {role}") + }; + + /// + /// Maps UniGetUI manager names to the broker protocol canonical names. + /// Only WinGet is supported in this iteration. + /// + private static string MapManagerName(string managerName) + { + // UniGetUI uses "Winget" internally for the WinGet manager. + if (managerName.Equals("Winget", StringComparison.OrdinalIgnoreCase) || + managerName.Equals("WinGet", StringComparison.OrdinalIgnoreCase)) + { + return "Winget"; + } + + // Return as-is for unsupported managers (broker will reject). + return managerName; + } + + private static string? MapScope(string? scope) + { + if (string.IsNullOrEmpty(scope)) return null; + return scope.ToLowerInvariant() switch + { + "user" => "user", + "machine" => "machine", + "global" => "machine", + _ => scope.ToLowerInvariant() + }; + } + + private static List GetCustomParameters(InstallOptions options, OperationType role) => role switch + { + OperationType.Install => options.CustomParameters_Install ?? [], + OperationType.Update => options.CustomParameters_Update ?? [], + OperationType.Uninstall => options.CustomParameters_Uninstall ?? [], + _ => [] + }; + + private static string? GetPreCommand(InstallOptions options, OperationType role) => role switch + { + OperationType.Install => NullIfEmpty(options.PreInstallCommand), + OperationType.Update => NullIfEmpty(options.PreUpdateCommand), + OperationType.Uninstall => NullIfEmpty(options.PreUninstallCommand), + _ => null + }; + + private static string? GetPostCommand(InstallOptions options, OperationType role) => role switch + { + OperationType.Install => NullIfEmpty(options.PostInstallCommand), + OperationType.Update => NullIfEmpty(options.PostUpdateCommand), + OperationType.Uninstall => NullIfEmpty(options.PostUninstallCommand), + _ => null + }; + + private static string? NullIfEmpty(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value; +} diff --git a/src/UniGetUI.PackageEngine.AgentBroker/UniGetUI.PackageEngine.AgentBroker.csproj b/src/UniGetUI.PackageEngine.AgentBroker/UniGetUI.PackageEngine.AgentBroker.csproj new file mode 100644 index 0000000000..832d44a9f7 --- /dev/null +++ b/src/UniGetUI.PackageEngine.AgentBroker/UniGetUI.PackageEngine.AgentBroker.csproj @@ -0,0 +1,20 @@ + + + + $(SharedTargetFrameworks) + UniGetUI.PackageEngine.AgentBroker + + + + + + + + + + + + + + + diff --git a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs index 56741694d2..12e6043c70 100644 --- a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs +++ b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs @@ -4,6 +4,7 @@ using UniGetUI.Core.SettingsEngine; using UniGetUI.Core.Tools; using UniGetUI.Interface.Enums; +using UniGetUI.PackageEngine.AgentBroker; using UniGetUI.PackageEngine.Classes.Packages.Classes; using UniGetUI.PackageEngine.Enums; using UniGetUI.PackageEngine.Interfaces; @@ -148,6 +149,105 @@ protected sealed override void PrepareProcessStartInfo() ); } + /// + /// Override to intercept operations and route through the Devolutions Agent broker + /// when the UseAgentBroker setting is enabled and the manager supports it (WinGet only for now). + /// Falls back to process-based execution otherwise. + /// + protected override async Task PerformOperation() + { + if (!ShouldUseAgentBroker()) + { + return await base.PerformOperation(); + } + + return await PerformBrokerOperation(); + } + + /// + /// Determines whether this operation should be routed through the agent broker. + /// + private bool ShouldUseAgentBroker() + { + // NOTE: Change this condition to enable agent broker by default when ready. + // Currently opt-in via settings. + bool settingEnabled = Settings.Get(Settings.K.UseAgentBroker); + bool isWinGet = IsWinGetManager(Package.Manager); + Logger.Info($"[AgentBroker] ShouldUseAgentBroker check: setting={settingEnabled}, isWinGet={isWinGet}, manager={Package.Manager.Name}"); + + if (!settingEnabled) + { + return false; + } + + // Only WinGet is supported in this iteration. + if (!isWinGet) + { + return false; + } + + return true; + } + + /// + /// Perform the package operation through the Devolutions Agent broker. + /// Sends the request over named pipe and interprets the response. + /// + private async Task PerformBrokerOperation() + { + Line("Routing operation through Devolutions Agent broker...", LineType.Information); + + using var client = new BrokerClient(); + + // Check broker availability. + if (!await client.IsAvailableAsync()) + { + Line("Agent broker is not available, falling back to local execution.", LineType.Information); + Logger.Warn("[AgentBroker] Broker not available, falling back to process execution"); + return await base.PerformOperation(); + } + + // Build the broker request. + var request = BrokerRequestBuilder.Build(Package, Options, Role); + + Line($"Sending request to broker: {request.RequestId}", LineType.VerboseDetails); + Line($" Package: {request.Package.Id} ({request.Operation})", LineType.VerboseDetails); + Line($" Manager: {request.Manager.Name}", LineType.VerboseDetails); + Line($" User: {request.Broker.EffectiveUser}", LineType.VerboseDetails); + + // Send to broker and poll until completion. + var status = await client.ExecuteAndWaitAsync(request); + + if (status is null) + { + Line("No response from broker — the operation could not be submitted.", LineType.Error); + Logger.Error("[AgentBroker] ExecuteAndWaitAsync returned null"); + Metadata.FailureTitle = CoreTools.Translate("Broker communication error"); + Metadata.FailureMessage = CoreTools.Translate("The agent broker did not respond. Ensure Devolutions Agent is running."); + return OperationVeredict.Failure; + } + + // Log status details. + Line($"Broker status: {status.Status}, exitCode={status.ExitCode}", LineType.Information); + if (!string.IsNullOrWhiteSpace(status.Note)) + { + Line($" Note: {status.Note}", LineType.Information); + } + + if (status.Status == "completed" && status.ExitCode == 0) + { + Line("Operation completed successfully via agent broker.", LineType.Information); + return OperationVeredict.Success; + } + + // Operation failed — surface a user-visible error. + string reason = status.Note ?? $"Exit code: {status.ExitCode}"; + Line($"Operation failed via broker: {reason}", LineType.Error); + Metadata.FailureTitle = CoreTools.Translate("Operation denied or failed via broker"); + Metadata.FailureMessage = reason; + return OperationVeredict.Failure; + } + protected sealed override Task GetProcessVeredict( int ReturnCode, List Output diff --git a/src/UniGetUI.PackageEngine.Operations/UniGetUI.PackageEngine.Operations.csproj b/src/UniGetUI.PackageEngine.Operations/UniGetUI.PackageEngine.Operations.csproj index 6b664b6de2..0c8c3cbec3 100644 --- a/src/UniGetUI.PackageEngine.Operations/UniGetUI.PackageEngine.Operations.csproj +++ b/src/UniGetUI.PackageEngine.Operations/UniGetUI.PackageEngine.Operations.csproj @@ -9,6 +9,7 @@ + diff --git a/src/UniGetUI.Windows.slnx b/src/UniGetUI.Windows.slnx index d3ecc29b0e..1633590b49 100644 --- a/src/UniGetUI.Windows.slnx +++ b/src/UniGetUI.Windows.slnx @@ -118,6 +118,10 @@ + + + +