From dd2133b9766520e281761ee454a4241c4fce449e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 8 Sep 2026 17:24:42 -0400 Subject: [PATCH 01/39] test(agent): cover policy management end to end Exercise policy management through a protected standard-user client and a LocalSystem client without weakening executable or path checks. Cover validation and write denial, managed Create, Update, Repair, stale conflicts, confirmed overwrite, restart persistence, durable managed authority, and observable audit outcomes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 2 + crates/agent-policy-tester/Cargo.toml | 2 + crates/agent-policy-tester/run-as-system.ps1 | 2 +- crates/agent-policy-tester/run-unelevated.ps1 | 139 ++++++ crates/agent-policy-tester/src/windows.rs | 458 +++++++++++++++++- 5 files changed, 577 insertions(+), 26 deletions(-) create mode 100644 crates/agent-policy-tester/run-unelevated.ps1 diff --git a/Cargo.lock b/Cargo.lock index a3aade761..2be74766a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,6 +94,8 @@ dependencies = [ "serde_json", "tempfile", "tokio 1.52.3", + "win-api-wrappers", + "windows 0.61.3", ] [[package]] diff --git a/crates/agent-policy-tester/Cargo.toml b/crates/agent-policy-tester/Cargo.toml index ba2f20f77..0c549b5d1 100644 --- a/crates/agent-policy-tester/Cargo.toml +++ b/crates/agent-policy-tester/Cargo.toml @@ -12,6 +12,8 @@ fastrand = "2" serde_json = "1" tempfile = "3" tokio = { version = "1", features = ["io-util", "macros", "net", "process", "rt-multi-thread", "time"] } +win-api-wrappers = { path = "../win-api-wrappers" } +windows = { version = "0.61", features = ["Win32_Security"] } [lints] workspace = true diff --git a/crates/agent-policy-tester/run-as-system.ps1 b/crates/agent-policy-tester/run-as-system.ps1 index 8520d10ec..14e9028e5 100644 --- a/crates/agent-policy-tester/run-as-system.ps1 +++ b/crates/agent-policy-tester/run-as-system.ps1 @@ -76,7 +76,7 @@ public static class AgentPolicyTesterNativeDirectory "Staged policy tester at $stagedTesterPath" | Out-File $outputPath -Append Get-Acl -LiteralPath $stagingPath | Format-List Owner, Sddl | Out-File $outputPath -Append Get-Acl -LiteralPath $stagedTesterPath | Format-List Owner, Sddl | Out-File $outputPath -Append - & $stagedTesterPath $agentPath 2>&1 | Out-File $outputPath -Append + & $stagedTesterPath $agentPath elevated 2>&1 | Out-File $outputPath -Append $exitCode = $LASTEXITCODE } catch { $_ | Out-File $outputPath -Append diff --git a/crates/agent-policy-tester/run-unelevated.ps1 b/crates/agent-policy-tester/run-unelevated.ps1 new file mode 100644 index 000000000..341edc706 --- /dev/null +++ b/crates/agent-policy-tester/run-unelevated.ps1 @@ -0,0 +1,139 @@ +param( + [ValidateSet("Orchestrate", "Stage", "Run", "Cleanup")] + [string] $Action = "Orchestrate", + [string] $TesterPath, + [string] $StagedTesterPath, + [string] $StagingPath, + [string] $AgentPath, + [string] $TempPath +) + +$ErrorActionPreference = "Stop" + +if ($Action -eq "Run") { + $env:TEMP = $TempPath + $env:TMP = $TempPath + & $StagedTesterPath $AgentPath unelevated + exit $LASTEXITCODE +} + +if ($Action -eq "Cleanup") { + for ($attempt = 0; $attempt -lt 20 -and (Test-Path -LiteralPath $StagingPath); $attempt++) { + try { + Remove-Item -LiteralPath $StagingPath -Recurse -Force + } catch { + if ($attempt -eq 19) { + throw "Failed to remove $StagingPath after 20 attempts: $_" + } + Start-Sleep -Milliseconds 250 + } + } + exit $(if (Test-Path -LiteralPath $StagingPath) { 1 } else { 0 }) +} + +if ($Action -eq "Stage") { + Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; + +public static class AgentPolicyUnelevatedTesterNativeDirectory +{ + [StructLayout(LayoutKind.Sequential)] + private struct SecurityAttributes + { + internal int Length; + internal IntPtr SecurityDescriptor; + internal int InheritHandle; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CreateDirectoryW(string path, ref SecurityAttributes securityAttributes); + + public static void Create(string path, byte[] securityDescriptor) + { + GCHandle pinnedDescriptor = GCHandle.Alloc(securityDescriptor, GCHandleType.Pinned); + try + { + SecurityAttributes attributes = new SecurityAttributes + { + Length = Marshal.SizeOf(), + SecurityDescriptor = pinnedDescriptor.AddrOfPinnedObject(), + InheritHandle = 0, + }; + if (!CreateDirectoryW(path, ref attributes)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + } + finally + { + pinnedDescriptor.Free(); + } + } +} +'@ + $directorySecurity = [System.Security.AccessControl.DirectorySecurity]::new() + $directorySecurity.SetSecurityDescriptorSddlForm( + 'O:SYG:SYD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;BU)' + ) + [AgentPolicyUnelevatedTesterNativeDirectory]::Create( + $StagingPath, + $directorySecurity.GetSecurityDescriptorBinaryForm() + ) + if (Get-ChildItem -LiteralPath $StagingPath -Force) { + throw "The atomically protected staged tester directory was not empty" + } + + Copy-Item -LiteralPath $TesterPath -Destination $StagedTesterPath + & icacls.exe $StagedTesterPath /setowner '*S-1-5-18' + if ($LASTEXITCODE -ne 0) { + throw "Failed to set the staged tester owner" + } + & icacls.exe $StagedTesterPath /inheritance:r /grant:r '*S-1-5-18:(F)' '*S-1-5-32-544:(F)' '*S-1-5-32-545:(RX)' + if ($LASTEXITCODE -ne 0) { + throw "Failed to protect the staged tester executable" + } + Get-Acl -LiteralPath $StagingPath | Format-List Owner, Sddl + Get-Acl -LiteralPath $StagedTesterPath | Format-List Owner, Sddl + exit 0 +} + +$workspacePath = (Resolve-Path (Join-Path $PSScriptRoot "../..")).Path +$testerPath = Join-Path $workspacePath "target/debug/agent-policy-tester.exe" +$agentPath = Join-Path $workspacePath "target/debug/devolutions-agent.exe" +$outputPath = Join-Path $PSScriptRoot "agent-policy-tester-unelevated.out" +$stagingPath = Join-Path $env:ProgramData "dgw-agent-policy-tester-$([guid]::NewGuid().ToString('N'))" +$stagedTesterPath = Join-Path $stagingPath "agent-policy-tester.exe" +$tempPath = Join-Path $env:USERPROFILE "AppData\LocalLow\Temp" + +try { + Set-Content -LiteralPath $outputPath -Value "" + New-Item -ItemType Directory -Path $tempPath -Force | Out-Null + + $stageOutput = & psexec.exe -accepteula -s pwsh.exe -NoProfile -File $PSCommandPath ` + -Action Stage -TesterPath $testerPath -StagedTesterPath $stagedTesterPath -StagingPath $stagingPath 2>&1 + $stageExitCode = $LASTEXITCODE + $stageOutput | Out-File $outputPath -Append + if ($stageExitCode -ne 0) { + throw "LocalSystem tester staging failed with exit code $stageExitCode" + } + + $testerOutput = & psexec.exe -accepteula -l pwsh.exe -NoProfile -File $PSCommandPath ` + -Action Run -StagedTesterPath $stagedTesterPath -AgentPath $agentPath -TempPath $tempPath 2>&1 + $exitCode = $LASTEXITCODE + $testerOutput | Out-File $outputPath -Append +} catch { + $_ | Out-File $outputPath -Append + $exitCode = 1 +} finally { + $cleanupOutput = & psexec.exe -accepteula -s pwsh.exe -NoProfile -File $PSCommandPath ` + -Action Cleanup -StagingPath $stagingPath 2>&1 + $cleanupExitCode = $LASTEXITCODE + $cleanupOutput | Out-File $outputPath -Append + if ($cleanupExitCode -ne 0 -and $exitCode -eq 0) { + $exitCode = $cleanupExitCode + } +} + +exit $exitCode diff --git a/crates/agent-policy-tester/src/windows.rs b/crates/agent-policy-tester/src/windows.rs index db8d93d22..855f555fc 100644 --- a/crates/agent-policy-tester/src/windows.rs +++ b/crates/agent-policy-tester/src/windows.rs @@ -6,14 +6,21 @@ use anyhow::{Context as _, bail, ensure}; use serde_json::{Value, json}; use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; use tokio::net::windows::named_pipe::ClientOptions; +use win_api_wrappers::identity::sid::Sid; +use win_api_wrappers::process::Process; +use windows::Win32::Security::{TOKEN_DUPLICATE, TOKEN_QUERY, WinBuiltinAdministratorsSid, WinLocalSystemSid}; const FULL_POLICY: &str = include_str!("../../now-package-broker/src/assets/samples/corporate-allowlist.policy.json"); +const MANAGED_POLICY_RELATIVE_PATH: &str = r"Devolutions\PackageBroker\package-broker-policy.json"; +const MANAGED_AUTHORITY_MARKER: &str = r"Devolutions\PackageBroker\.package-broker-managed-authority.v1"; +const LEGACY_POLICY_RELATIVE_PATH: &str = r"Devolutions\Agent\package-broker-policy.json"; struct AgentHarness { child: tokio::process::Child, - _data_dir: tempfile::TempDir, + data_dir: tempfile::TempDir, pipe_name: String, policy_path: PathBuf, + program_data: Option, } impl AgentHarness { @@ -34,17 +41,43 @@ impl AgentHarness { Self::start_with_path(agent_path, data_dir, pipe_name, policy_path).await } + async fn start_unelevated(agent_path: &Path) -> anyhow::Result { + let data_dir = tempfile::tempdir().context("create unelevated Agent data directory")?; + let pipe_name = unique_pipe_name(); + let policy_path = data_dir.path().join("policy.json"); + Self::start_with_options(agent_path, data_dir, pipe_name, policy_path, false).await + } + + async fn start_managed_default(agent_path: &Path) -> anyhow::Result { + let data_dir = create_data_dir()?; + let pipe_name = unique_pipe_name(); + let policy_path = data_dir.path().join(MANAGED_POLICY_RELATIVE_PATH); + Self::start_with_options(agent_path, data_dir, pipe_name, policy_path, true).await + } + async fn start_with_path( agent_path: &Path, data_dir: tempfile::TempDir, pipe_name: String, policy_path: PathBuf, ) -> anyhow::Result { + Self::start_with_options(agent_path, data_dir, pipe_name, policy_path, false).await + } + + async fn start_with_options( + agent_path: &Path, + data_dir: tempfile::TempDir, + pipe_name: String, + policy_path: PathBuf, + use_managed_default: bool, + ) -> anyhow::Result { + let policy_path_config = (!use_managed_default).then_some(&policy_path); let config = json!({ + "LogFile": data_dir.path().join("agent-e2e"), "PackageBroker": { "Enabled": true, "PipeName": pipe_name, - "PolicyPath": policy_path, + "PolicyPath": policy_path_config, }, "__debug__": { "skip_broker_signature_validation": true, @@ -53,26 +86,35 @@ impl AgentHarness { std::fs::write(data_dir.path().join("agent.json"), serde_json::to_vec_pretty(&config)?) .context("write Agent configuration")?; - let child = tokio::process::Command::new(agent_path) - .env("DAGENT_CONFIG_PATH", data_dir.path()) - .arg("run") - .kill_on_drop(true) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .context("start Devolutions Agent")?; + let program_data = use_managed_default.then(|| data_dir.path().to_owned()); + let child = Self::spawn(agent_path, data_dir.path(), program_data.as_deref())?; let mut harness = Self { child, - _data_dir: data_dir, + data_dir, pipe_name, policy_path, + program_data, }; harness.wait_until_ready().await?; Ok(harness) } + fn spawn(agent_path: &Path, data_dir: &Path, program_data: Option<&Path>) -> anyhow::Result { + let mut command = tokio::process::Command::new(agent_path); + command + .env("DAGENT_CONFIG_PATH", data_dir) + .arg("run") + .kill_on_drop(true) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + if let Some(program_data) = program_data { + command.env("ProgramData", program_data); + } + command.spawn().context("start Devolutions Agent") + } + async fn wait_until_ready(&mut self) -> anyhow::Result<()> { let deadline = Instant::now() + Duration::from_secs(20); @@ -89,6 +131,34 @@ impl AgentHarness { } } } + + async fn restart(&mut self, agent_path: &Path) -> anyhow::Result<()> { + self.stop().await?; + self.start_again(agent_path).await + } + + async fn stop(&mut self) -> anyhow::Result<()> { + self.child.start_kill().context("stop Devolutions Agent")?; + self.child.wait().await.context("wait for Devolutions Agent to stop")?; + Ok(()) + } + + async fn start_again(&mut self, agent_path: &Path) -> anyhow::Result<()> { + self.child = Self::spawn(agent_path, self.data_dir.path(), self.program_data.as_deref())?; + self.wait_until_ready().await + } + + fn logs(&self) -> anyhow::Result { + let mut logs = String::new(); + for entry in std::fs::read_dir(self.data_dir.path()).context("read Agent log directory")? { + let entry = entry.context("read Agent log entry")?; + let name = entry.file_name(); + if name.to_string_lossy().starts_with("agent-e2e") { + logs.push_str(&std::fs::read_to_string(entry.path()).context("read Agent log")?); + } + } + Ok(logs) + } } impl Drop for AgentHarness { @@ -108,25 +178,94 @@ impl HttpResponse { } } +#[derive(Clone, Copy, PartialEq, Eq)] +enum Mode { + Unelevated, + Elevated, +} + +impl Mode { + fn parse(value: &str) -> anyhow::Result { + match value { + "unelevated" => Ok(Self::Unelevated), + "elevated" => Ok(Self::Elevated), + _ => bail!("unknown mode '{value}'; expected 'unelevated' or 'elevated'"), + } + } +} + pub(crate) async fn run() -> anyhow::Result<()> { - let agent_path = std::env::args_os() - .nth(1) + let mut args = std::env::args_os().skip(1); + let agent_path = args + .next() .map(PathBuf::from) - .context("usage: agent-policy-tester ")?; + .context("usage: agent-policy-tester ")?; + let mode = args + .next() + .and_then(|value| value.into_string().ok()) + .context("test mode must be 'unelevated' or 'elevated'") + .and_then(|value| Mode::parse(&value))?; + ensure!(args.next().is_none(), "unexpected extra command-line arguments"); + verify_process_token(mode)?; ensure!( agent_path.is_file(), "agent executable does not exist: {}", agent_path.display() ); - unavailable_policy_and_method_restrictions(&agent_path).await?; - complete_snapshots_across_reload(&agent_path).await?; - redirected_policy_paths_fail_closed(&agent_path).await?; - management_write_tokens_survive_watcher_reload(&agent_path).await?; + match mode { + Mode::Unelevated => standard_user_management(&agent_path).await?, + Mode::Elevated => { + unavailable_policy_and_method_restrictions(&agent_path).await?; + complete_snapshots_across_reload(&agent_path).await?; + redirected_policy_paths_fail_closed(&agent_path).await?; + management_write_tokens_survive_watcher_reload(&agent_path).await?; + managed_policy_lifecycle(&agent_path).await?; + } + } Ok(()) } +fn verify_process_token(mode: Mode) -> anyhow::Result<()> { + let token = Process::current_process() + .token(TOKEN_QUERY | TOKEN_DUPLICATE) + .context("open tester process token")?; + let administrators = + Sid::from_well_known(WinBuiltinAdministratorsSid, None).context("construct Administrators SID")?; + let is_administrator = token + .is_member(&administrators) + .context("query tester Administrators membership")?; + let system = Sid::from_well_known(WinLocalSystemSid, None).context("construct LocalSystem SID")?; + let user = token.sid_and_attributes().context("query tester user SID")?.sid; + match mode { + Mode::Unelevated => { + ensure!(user != system, "unelevated mode requires a standard user account"); + ensure!( + !is_administrator, + "unelevated mode requires disabled Administrators membership" + ); + } + Mode::Elevated => { + ensure!(is_administrator, "elevated mode requires Administrators membership"); + ensure!( + token.is_elevated().context("query tester token elevation")?, + "elevated mode requires an elevated token" + ); + ensure!(user == system, "elevated mode requires the LocalSystem account"); + } + } + Ok(()) +} + +fn unique_pipe_name() -> String { + format!( + r"\\.\pipe\Devolutions.Now.PackageBroker.tests.{}.{}", + std::process::id(), + fastrand::u64(..) + ) +} + async fn request(pipe_name: &str, method: &str, path: &str) -> anyhow::Result { request_with_body(pipe_name, method, path, None, &[]).await } @@ -360,12 +499,7 @@ async fn policy_management(agent: &AgentHarness) -> anyhow::Result { Ok(response.json()?["Management"].clone()) } -async fn replace_policy( - agent: &AgentHarness, - operation: &str, - expected_store_token: Value, - draft: Value, -) -> anyhow::Result { +async fn validate_policy(agent: &AgentHarness, draft: &Value) -> anyhow::Result { let validation_request = json!({ "RequestKind": "PolicyValidationRequest", "RequestVersion": "1.0", @@ -386,12 +520,24 @@ async fn replace_policy( ); let validation = validation_response.json()?["Validation"].clone(); ensure!(validation["IsValid"] == true, "policy validation failed"); + Ok(validation) +} + +async fn replace_policy_response( + agent: &AgentHarness, + operation: &str, + conflict_handling: &str, + expected_store_token: Value, + draft: Value, +) -> anyhow::Result { + let validation = validate_policy(agent, &draft).await?; let replacement_request = json!({ "RequestKind": "PolicyReplacementRequest", "RequestVersion": "1.0", "ExpectedStoreToken": expected_store_token, "Operation": operation, - "ConflictHandling": "Reject", + "ConflictHandling": conflict_handling, + "WarningsAcknowledged": true, "Draft": validation["CanonicalDraft"], "ValidationReceipt": validation["ValidationReceipt"] }); @@ -403,6 +549,16 @@ async fn replace_policy( &serde_json::to_vec(&replacement_request)?, ) .await?; + Ok(response) +} + +async fn replace_policy( + agent: &AgentHarness, + operation: &str, + expected_store_token: Value, + draft: Value, +) -> anyhow::Result { + let response = replace_policy_response(agent, operation, "Reject", expected_store_token, draft).await?; ensure!(response.status == 200, "{operation} returned HTTP {}", response.status); response.json() } @@ -459,6 +615,258 @@ async fn management_write_tokens_survive_watcher_reload(agent_path: &Path) -> an Ok(()) } +async fn standard_user_management(agent_path: &Path) -> anyhow::Result<()> { + let agent = AgentHarness::start_unelevated(agent_path).await?; + let management = policy_management(&agent).await?; + ensure!(management["State"] == "Missing", "expected a missing policy"); + + let valid_draft = policy_draft("tests.standard-user", "Test"); + let validation = validate_policy(&agent, &valid_draft).await?; + ensure!( + validation["CanonicalDraft"].is_object() && validation["ValidationReceipt"].is_string(), + "valid draft did not produce a canonical draft and receipt" + ); + + let mut invalid_draft = valid_draft.clone(); + invalid_draft["$schema"] = json!("https://example.com/not-the-policy-draft-schema.json"); + let invalid_request = json!({ + "RequestKind": "PolicyValidationRequest", + "RequestVersion": "1.0", + "Draft": invalid_draft + }); + let invalid_response = request_with_body( + &agent.pipe_name, + "POST", + "/v1/policy/validate", + Some("application/json"), + &serde_json::to_vec(&invalid_request)?, + ) + .await?; + ensure!( + invalid_response.status == 200, + "invalid draft validation returned HTTP {}", + invalid_response.status + ); + let invalid_validation = invalid_response.json()?["Validation"].clone(); + ensure!(invalid_validation["IsValid"] == false, "invalid draft was accepted"); + ensure!( + invalid_validation.get("CanonicalDraft").is_none(), + "invalid draft returned a canonical draft" + ); + + let denied = replace_policy_response( + &agent, + "Create", + "Reject", + management["StoreToken"].clone(), + valid_draft, + ) + .await?; + ensure!( + denied.status == 403, + "standard-user Create returned HTTP {}", + denied.status + ); + ensure!( + denied.json()?["Code"] == "AdministratorRequired", + "standard-user Create did not require an administrator" + ); + wait_for_log(&agent, "Policy management write denied").await +} + +async fn managed_policy_lifecycle(agent_path: &Path) -> anyhow::Result<()> { + let mut agent = AgentHarness::start_managed_default(agent_path).await?; + let initial = policy_management(&agent).await?; + ensure!( + initial["State"] == "Missing", + "managed policy was not initially missing" + ); + ensure!( + initial["Source"] == "DefaultPath" && initial["WriteCapability"] == "Writable", + "isolated managed default path was not writable" + ); + + let created = replace_policy( + &agent, + "Create", + initial["StoreToken"].clone(), + policy_draft("tests.managed-lifecycle", "Create"), + ) + .await?; + ensure!( + created["Policy"]["Metadata"]["Revision"] == 1, + "Create did not assign revision 1" + ); + let authority_marker = agent.data_dir.path().join(MANAGED_AUTHORITY_MARKER); + ensure!( + authority_marker.is_file() && std::fs::metadata(&authority_marker)?.len() == 0, + "Create did not establish durable managed authority" + ); + wait_for_log(&agent, "Policy creation succeeded").await?; + + let updated = replace_policy( + &agent, + "Update", + created["Management"]["StoreToken"].clone(), + policy_draft("tests.managed-lifecycle", "Update"), + ) + .await?; + ensure!( + updated["Policy"]["Metadata"]["Revision"] == 2, + "Update did not increment the revision" + ); + + let secret = "malformed-policy-secret-marker"; + std::fs::write(&agent.policy_path, format!(r#"{{"unterminated":"{secret}"#)) + .context("write malformed external policy")?; + let invalid = wait_for_management(&agent, |management| management["State"] == "Invalid").await?; + let diagnostics = &invalid["InvalidDiagnostics"]; + ensure!( + diagnostics["Findings"] + .as_array() + .is_some_and(|findings| !findings.is_empty()), + "invalid policy did not produce diagnostics" + ); + ensure!( + !diagnostics.to_string().contains(secret), + "invalid policy diagnostics exposed file contents" + ); + wait_for_log(&agent, "External policy change rejected").await?; + + let repaired = replace_policy( + &agent, + "Repair", + invalid["StoreToken"].clone(), + policy_draft("tests.managed-repaired", "Repair"), + ) + .await?; + ensure!( + repaired["Policy"]["Metadata"]["Revision"] == 1, + "Repair did not assign revision 1" + ); + + let stale_token = repaired["Management"]["StoreToken"].clone(); + let mut external = empty_policy(); + external["Metadata"]["Id"] = json!("tests.managed-external"); + std::fs::write(&agent.policy_path, serde_json::to_vec_pretty(&external)?).context("write valid external policy")?; + wait_for_management(&agent, |management| { + management["Policy"]["Metadata"]["Id"] == "tests.managed-external" + }) + .await?; + wait_for_log(&agent, "External policy change applied").await?; + + let stale = replace_policy_response( + &agent, + "Update", + "Reject", + stale_token, + policy_draft("tests.managed-external", "Stale"), + ) + .await?; + ensure!(stale.status == 409, "stale Update returned HTTP {}", stale.status); + let stale = stale.json()?; + ensure!( + stale["Code"] == "StalePolicyStoreToken" + && stale["Management"]["Policy"]["Metadata"]["Id"] == "tests.managed-external", + "stale Update did not return the current policy snapshot" + ); + wait_for_log(&agent, "stale_conflict").await?; + + let current_token = stale["Management"]["StoreToken"].clone(); + let confirmed = replace_policy_response( + &agent, + "Update", + "ConfirmOverwrite", + current_token.clone(), + policy_draft("tests.managed-external", "Confirmed overwrite"), + ) + .await?; + ensure!( + confirmed.status == 200, + "exact ConfirmOverwrite returned HTTP {}", + confirmed.status + ); + let confirmed = confirmed.json()?; + ensure!( + confirmed["Policy"]["Metadata"]["Revision"] == 2, + "confirmed Update did not increment the external policy revision" + ); + wait_for_log(&agent, "confirmed_overwrite").await?; + + let reused = replace_policy_response( + &agent, + "Update", + "ConfirmOverwrite", + current_token, + policy_draft("tests.managed-external", "Reused token"), + ) + .await?; + ensure!( + reused.status == 409 && reused.json()?["Code"] == "StalePolicyStoreToken", + "reused ConfirmOverwrite token did not conflict" + ); + + agent.restart(agent_path).await?; + let restarted = request(&agent.pipe_name, "GET", "/v1/policy").await?; + ensure!( + restarted.status == 200, + "policy read after restart returned HTTP {}", + restarted.status + ); + ensure!( + restarted.json()?["Policy"] == confirmed["Policy"], + "restart changed the active managed policy" + ); + ensure!( + authority_marker.is_file() && policy_management(&agent).await?["Source"] == "DefaultPath", + "restart lost durable managed authority" + ); + + agent.stop().await?; + let legacy_path = agent.data_dir.path().join(LEGACY_POLICY_RELATIVE_PATH); + let legacy_dir = legacy_path.parent().context("legacy policy path has no parent")?; + std::fs::create_dir_all(legacy_dir).context("create isolated legacy policy directory")?; + secure_policy_path(legacy_dir, true)?; + std::fs::write(&legacy_path, serde_json::to_vec_pretty(&empty_policy())?) + .context("write isolated legacy policy")?; + secure_policy_path(&legacy_path, false)?; + std::fs::remove_file(&agent.policy_path).context("remove managed policy before authority restart")?; + agent.start_again(agent_path).await?; + let authority = policy_management(&agent).await?; + ensure!( + authority["State"] == "Missing" && authority["Source"] == "DefaultPath", + "durable managed authority allowed legacy policy rollback" + ); + ensure!( + request(&agent.pipe_name, "GET", "/v1/policy").await?.status == 404, + "legacy policy became active after managed authority was established" + ); + Ok(()) +} + +async fn wait_for_management(agent: &AgentHarness, predicate: impl Fn(&Value) -> bool) -> anyhow::Result { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let management = policy_management(agent).await?; + if predicate(&management) { + return Ok(management); + } + ensure!(Instant::now() < deadline, "timed out waiting for policy state"); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +async fn wait_for_log(agent: &AgentHarness, expected: &str) -> anyhow::Result<()> { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if agent.logs()?.contains(expected) { + return Ok(()); + } + ensure!(Instant::now() < deadline, "Agent log did not contain '{expected}'"); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + async fn unavailable_policy_and_method_restrictions(agent_path: &Path) -> anyhow::Result<()> { let agent = AgentHarness::start(agent_path, None).await?; From 2bec3e69580cefbc7435ef865b2e9be0bd2c8c8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 8 Sep 2026 17:24:52 -0400 Subject: [PATCH 02/39] ci(agent): run policy E2E as both identities Run the protected policy tester under a restricted standard-user token before the existing LocalSystem lifecycle. Keep feature-gated route authorization tests in the same Windows job so the development signature feature cannot hide them from the default workspace suite. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3cb5836f..cecf7f4af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1424,6 +1424,16 @@ jobs: exit $LASTEXITCODE } + - name: Run Agent policy tester as standard user + shell: pwsh + run: | + ./crates/agent-policy-tester/run-unelevated.ps1 + $exitCode = $LASTEXITCODE + Get-Content -Path ./crates/agent-policy-tester/agent-policy-tester-unelevated.out + if ($exitCode -ne 0) { + exit $exitCode + } + - name: Run Agent policy tester as LocalSystem shell: pwsh run: | @@ -1435,6 +1445,10 @@ jobs: exit $exitCode } + - name: Run policy route authorization tests + shell: pwsh + run: cargo test --locked -p now-package-broker --features dev-skip-broker-signature + - name: Show sccache stats if: ${{ needs.preflight.outputs.sccache == 'true' && !cancelled() }} shell: pwsh From 38cc31cc3c3f67bf81d58b43211c1d2b5c656b65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 8 Sep 2026 21:45:45 -0400 Subject: [PATCH 03/39] test(agent): separate policy client identity Keep the Agent and test server under LocalSystem while a distinct restricted process exercises the named-pipe management endpoints. Coordinate readiness and shutdown through a protected, read-only test directory so authorization regressions cannot pass by inspecting the server token. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/agent-policy-tester/Cargo.toml | 2 +- crates/agent-policy-tester/run-unelevated.ps1 | 93 ++++++- crates/agent-policy-tester/src/windows.rs | 258 ++++++++++++++---- 3 files changed, 299 insertions(+), 54 deletions(-) diff --git a/crates/agent-policy-tester/Cargo.toml b/crates/agent-policy-tester/Cargo.toml index 0c549b5d1..69f0f8d67 100644 --- a/crates/agent-policy-tester/Cargo.toml +++ b/crates/agent-policy-tester/Cargo.toml @@ -13,7 +13,7 @@ serde_json = "1" tempfile = "3" tokio = { version = "1", features = ["io-util", "macros", "net", "process", "rt-multi-thread", "time"] } win-api-wrappers = { path = "../win-api-wrappers" } -windows = { version = "0.61", features = ["Win32_Security"] } +windows = { version = "0.61", features = ["Win32_Security", "Win32_System_Threading"] } [lints] workspace = true diff --git a/crates/agent-policy-tester/run-unelevated.ps1 b/crates/agent-policy-tester/run-unelevated.ps1 index 341edc706..6508507d3 100644 --- a/crates/agent-policy-tester/run-unelevated.ps1 +++ b/crates/agent-policy-tester/run-unelevated.ps1 @@ -1,11 +1,16 @@ param( - [ValidateSet("Orchestrate", "Stage", "Run", "Cleanup")] + [ValidateSet("Orchestrate", "Stage", "Server", "Run", "Signal", "Cleanup")] [string] $Action = "Orchestrate", [string] $TesterPath, [string] $StagedTesterPath, [string] $StagingPath, [string] $AgentPath, - [string] $TempPath + [string] $TempPath, + [string] $ReadyPath, + [string] $StopPath, + [string] $StatusPath, + [string] $ServerOutputPath, + [string] $Nonce ) $ErrorActionPreference = "Stop" @@ -13,10 +18,29 @@ $ErrorActionPreference = "Stop" if ($Action -eq "Run") { $env:TEMP = $TempPath $env:TMP = $TempPath - & $StagedTesterPath $AgentPath unelevated + & $StagedTesterPath $AgentPath standard-client $ReadyPath $Nonce exit $LASTEXITCODE } +if ($Action -eq "Server") { + try { + & $StagedTesterPath $AgentPath standard-server $ReadyPath $StopPath $Nonce 2>&1 | + Out-File -LiteralPath $ServerOutputPath + $exitCode = $LASTEXITCODE + } catch { + $_ | Out-File -LiteralPath $ServerOutputPath -Append + $exitCode = 1 + } finally { + Set-Content -LiteralPath $StatusPath -Value $exitCode + } + exit $exitCode +} + +if ($Action -eq "Signal") { + New-Item -ItemType File -Path $StopPath -ErrorAction Stop | Out-Null + exit 0 +} + if ($Action -eq "Cleanup") { for ($attempt = 0; $attempt -lt 20 -and (Test-Path -LiteralPath $StagingPath); $attempt++) { try { @@ -37,7 +61,7 @@ using System; using System.ComponentModel; using System.Runtime.InteropServices; -public static class AgentPolicyUnelevatedTesterNativeDirectory +public static class AgentPolicyStandardUserTesterDirectory { [StructLayout(LayoutKind.Sequential)] private struct SecurityAttributes @@ -77,7 +101,7 @@ public static class AgentPolicyUnelevatedTesterNativeDirectory $directorySecurity.SetSecurityDescriptorSddlForm( 'O:SYG:SYD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;BU)' ) - [AgentPolicyUnelevatedTesterNativeDirectory]::Create( + [AgentPolicyStandardUserTesterDirectory]::Create( $StagingPath, $directorySecurity.GetSecurityDescriptorBinaryForm() ) @@ -105,7 +129,14 @@ $agentPath = Join-Path $workspacePath "target/debug/devolutions-agent.exe" $outputPath = Join-Path $PSScriptRoot "agent-policy-tester-unelevated.out" $stagingPath = Join-Path $env:ProgramData "dgw-agent-policy-tester-$([guid]::NewGuid().ToString('N'))" $stagedTesterPath = Join-Path $stagingPath "agent-policy-tester.exe" +$readyPath = Join-Path $stagingPath "standard-user-ready.json" +$stopPath = Join-Path $stagingPath "standard-user-stop" +$statusPath = Join-Path $stagingPath "standard-user-server.status" +$serverOutputPath = Join-Path $stagingPath "standard-user-server.out" $tempPath = Join-Path $env:USERPROFILE "AppData\LocalLow\Temp" +$nonce = [guid]::NewGuid().ToString("N") +$exitCode = 1 +$serverStarted = $false try { Set-Content -LiteralPath $outputPath -Value "" @@ -119,14 +150,64 @@ try { throw "LocalSystem tester staging failed with exit code $stageExitCode" } + $serverOutput = & psexec.exe -accepteula -s -d pwsh.exe -NoProfile -File $PSCommandPath ` + -Action Server -StagedTesterPath $stagedTesterPath -AgentPath $agentPath -ReadyPath $readyPath ` + -StopPath $stopPath -StatusPath $statusPath -ServerOutputPath $serverOutputPath -Nonce $nonce 2>&1 + $serverStartExitCode = $LASTEXITCODE + $serverOutput | Out-File $outputPath -Append + if ($serverStartExitCode -ne 0) { + throw "LocalSystem test server failed to start with exit code $serverStartExitCode" + } + $serverStarted = $true + + $deadline = [DateTime]::UtcNow.AddSeconds(30) + while (-not (Test-Path -LiteralPath $readyPath)) { + if (Test-Path -LiteralPath $statusPath) { + throw "LocalSystem test server exited before publishing readiness" + } + if ([DateTime]::UtcNow -ge $deadline) { + throw "Timed out waiting for LocalSystem test server readiness" + } + Start-Sleep -Milliseconds 100 + } + Get-Content -LiteralPath $readyPath | Out-File $outputPath -Append + $testerOutput = & psexec.exe -accepteula -l pwsh.exe -NoProfile -File $PSCommandPath ` - -Action Run -StagedTesterPath $stagedTesterPath -AgentPath $agentPath -TempPath $tempPath 2>&1 + -Action Run -StagedTesterPath $stagedTesterPath -AgentPath $agentPath -TempPath $tempPath ` + -ReadyPath $readyPath -Nonce $nonce 2>&1 $exitCode = $LASTEXITCODE $testerOutput | Out-File $outputPath -Append } catch { $_ | Out-File $outputPath -Append $exitCode = 1 } finally { + if ($serverStarted) { + $signalOutput = & psexec.exe -accepteula -s pwsh.exe -NoProfile -File $PSCommandPath ` + -Action Signal -StopPath $stopPath 2>&1 + $signalExitCode = $LASTEXITCODE + $signalOutput | Out-File $outputPath -Append + if ($signalExitCode -ne 0 -and $exitCode -eq 0) { + $exitCode = $signalExitCode + } + + $deadline = [DateTime]::UtcNow.AddSeconds(30) + while (-not (Test-Path -LiteralPath $statusPath) -and [DateTime]::UtcNow -lt $deadline) { + Start-Sleep -Milliseconds 100 + } + if (Test-Path -LiteralPath $serverOutputPath) { + Get-Content -LiteralPath $serverOutputPath | Out-File $outputPath -Append + } + if (Test-Path -LiteralPath $statusPath) { + $serverExitCode = [int](Get-Content -LiteralPath $statusPath -Raw) + if ($serverExitCode -ne 0 -and $exitCode -eq 0) { + $exitCode = $serverExitCode + } + } elseif ($exitCode -eq 0) { + "Timed out waiting for LocalSystem test server shutdown" | Out-File $outputPath -Append + $exitCode = 1 + } + } + $cleanupOutput = & psexec.exe -accepteula -s pwsh.exe -NoProfile -File $PSCommandPath ` -Action Cleanup -StagingPath $stagingPath 2>&1 $cleanupExitCode = $LASTEXITCODE diff --git a/crates/agent-policy-tester/src/windows.rs b/crates/agent-policy-tester/src/windows.rs index 855f555fc..933904842 100644 --- a/crates/agent-policy-tester/src/windows.rs +++ b/crates/agent-policy-tester/src/windows.rs @@ -1,3 +1,5 @@ +use std::fs::OpenOptions; +use std::io::Write as _; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::time::{Duration, Instant}; @@ -9,6 +11,7 @@ use tokio::net::windows::named_pipe::ClientOptions; use win_api_wrappers::identity::sid::Sid; use win_api_wrappers::process::Process; use windows::Win32::Security::{TOKEN_DUPLICATE, TOKEN_QUERY, WinBuiltinAdministratorsSid, WinLocalSystemSid}; +use windows::Win32::System::Threading::PROCESS_QUERY_LIMITED_INFORMATION; const FULL_POLICY: &str = include_str!("../../now-package-broker/src/assets/samples/corporate-allowlist.policy.json"); const MANAGED_POLICY_RELATIVE_PATH: &str = r"Devolutions\PackageBroker\package-broker-policy.json"; @@ -180,16 +183,18 @@ impl HttpResponse { #[derive(Clone, Copy, PartialEq, Eq)] enum Mode { - Unelevated, + StandardServer, + StandardClient, Elevated, } impl Mode { fn parse(value: &str) -> anyhow::Result { match value { - "unelevated" => Ok(Self::Unelevated), + "standard-server" => Ok(Self::StandardServer), + "standard-client" => Ok(Self::StandardClient), "elevated" => Ok(Self::Elevated), - _ => bail!("unknown mode '{value}'; expected 'unelevated' or 'elevated'"), + _ => bail!("unknown mode '{value}'; expected 'standard-server', 'standard-client', or 'elevated'"), } } } @@ -199,23 +204,33 @@ pub(crate) async fn run() -> anyhow::Result<()> { let agent_path = args .next() .map(PathBuf::from) - .context("usage: agent-policy-tester ")?; + .context("usage: agent-policy-tester [mode arguments]")?; let mode = args .next() .and_then(|value| value.into_string().ok()) - .context("test mode must be 'unelevated' or 'elevated'") + .context("test mode is required") .and_then(|value| Mode::parse(&value))?; - ensure!(args.next().is_none(), "unexpected extra command-line arguments"); - verify_process_token(mode)?; - ensure!( - agent_path.is_file(), - "agent executable does not exist: {}", - agent_path.display() - ); - match mode { - Mode::Unelevated => standard_user_management(&agent_path).await?, + Mode::StandardServer => { + verify_local_system()?; + ensure_agent_path(&agent_path)?; + let ready_path = next_path(&mut args, "ready path")?; + let stop_path = next_path(&mut args, "stop path")?; + let nonce = next_string(&mut args, "coordination nonce")?; + ensure!(args.next().is_none(), "unexpected standard-server arguments"); + standard_user_server(&agent_path, &ready_path, &stop_path, &nonce).await?; + } + Mode::StandardClient => { + let client = verify_standard_user()?; + let ready_path = next_path(&mut args, "ready path")?; + let nonce = next_string(&mut args, "coordination nonce")?; + ensure!(args.next().is_none(), "unexpected standard-client arguments"); + standard_user_management(&ready_path, &nonce, &client).await?; + } Mode::Elevated => { + verify_local_system()?; + ensure_agent_path(&agent_path)?; + ensure!(args.next().is_none(), "unexpected elevated arguments"); unavailable_policy_and_method_restrictions(&agent_path).await?; complete_snapshots_across_reload(&agent_path).await?; redirected_policy_paths_fail_closed(&agent_path).await?; @@ -227,7 +242,21 @@ pub(crate) async fn run() -> anyhow::Result<()> { Ok(()) } -fn verify_process_token(mode: Mode) -> anyhow::Result<()> { +fn ensure_agent_path(agent_path: &Path) -> anyhow::Result<()> { + ensure!( + agent_path.is_file(), + "agent executable does not exist: {}", + agent_path.display() + ); + Ok(()) +} + +struct ProcessIdentity { + pid: u32, + sid: Sid, +} + +fn current_process_identity() -> anyhow::Result<(ProcessIdentity, bool, bool)> { let token = Process::current_process() .token(TOKEN_QUERY | TOKEN_DUPLICATE) .context("open tester process token")?; @@ -236,26 +265,59 @@ fn verify_process_token(mode: Mode) -> anyhow::Result<()> { let is_administrator = token .is_member(&administrators) .context("query tester Administrators membership")?; + let identity = ProcessIdentity { + pid: std::process::id(), + sid: token.sid_and_attributes().context("query tester user SID")?.sid, + }; + Ok(( + identity, + is_administrator, + token.is_elevated().context("query tester token elevation")?, + )) +} + +fn verify_standard_user() -> anyhow::Result { + let (identity, is_administrator, _) = current_process_identity()?; let system = Sid::from_well_known(WinLocalSystemSid, None).context("construct LocalSystem SID")?; - let user = token.sid_and_attributes().context("query tester user SID")?.sid; - match mode { - Mode::Unelevated => { - ensure!(user != system, "unelevated mode requires a standard user account"); - ensure!( - !is_administrator, - "unelevated mode requires disabled Administrators membership" - ); - } - Mode::Elevated => { - ensure!(is_administrator, "elevated mode requires Administrators membership"); - ensure!( - token.is_elevated().context("query tester token elevation")?, - "elevated mode requires an elevated token" - ); - ensure!(user == system, "elevated mode requires the LocalSystem account"); - } - } - Ok(()) + ensure!( + identity.sid != system, + "standard-client mode requires a non-SYSTEM account" + ); + ensure!( + !is_administrator, + "standard-client mode requires disabled Administrators membership" + ); + Ok(identity) +} + +fn verify_local_system() -> anyhow::Result { + let (identity, is_administrator, is_elevated) = current_process_identity()?; + let system = Sid::from_well_known(WinLocalSystemSid, None).context("construct LocalSystem SID")?; + ensure!(identity.sid == system, "this mode requires the LocalSystem account"); + ensure!(is_administrator && is_elevated, "LocalSystem token is not elevated"); + Ok(identity) +} + +fn process_sid(pid: u32) -> anyhow::Result { + Process::get_by_pid(pid, PROCESS_QUERY_LIMITED_INFORMATION) + .with_context(|| format!("open process {pid}"))? + .token(TOKEN_QUERY) + .with_context(|| format!("open process {pid} token"))? + .sid_and_attributes() + .with_context(|| format!("query process {pid} SID")) + .map(|identity| identity.sid) +} + +fn next_path(args: &mut impl Iterator, name: &str) -> anyhow::Result { + args.next() + .map(PathBuf::from) + .with_context(|| format!("missing {name}")) +} + +fn next_string(args: &mut impl Iterator, name: &str) -> anyhow::Result { + args.next() + .and_then(|value| value.into_string().ok()) + .with_context(|| format!("missing or non-Unicode {name}")) } fn unique_pipe_name() -> String { @@ -490,7 +552,11 @@ async fn redirected_policy_paths_fail_closed(agent_path: &Path) -> anyhow::Resul } async fn policy_management(agent: &AgentHarness) -> anyhow::Result { - let response = request(&agent.pipe_name, "GET", "/v1/policy/management").await?; + policy_management_by_pipe(&agent.pipe_name).await +} + +async fn policy_management_by_pipe(pipe_name: &str) -> anyhow::Result { + let response = request(pipe_name, "GET", "/v1/policy/management").await?; ensure!( response.status == 200, "GET /v1/policy/management returned HTTP {}", @@ -499,14 +565,14 @@ async fn policy_management(agent: &AgentHarness) -> anyhow::Result { Ok(response.json()?["Management"].clone()) } -async fn validate_policy(agent: &AgentHarness, draft: &Value) -> anyhow::Result { +async fn validate_policy_by_pipe(pipe_name: &str, draft: &Value) -> anyhow::Result { let validation_request = json!({ "RequestKind": "PolicyValidationRequest", "RequestVersion": "1.0", "Draft": draft }); let validation_response = request_with_body( - &agent.pipe_name, + pipe_name, "POST", "/v1/policy/validate", Some("application/json"), @@ -530,7 +596,24 @@ async fn replace_policy_response( expected_store_token: Value, draft: Value, ) -> anyhow::Result { - let validation = validate_policy(agent, &draft).await?; + replace_policy_response_by_pipe( + &agent.pipe_name, + operation, + conflict_handling, + expected_store_token, + draft, + ) + .await +} + +async fn replace_policy_response_by_pipe( + pipe_name: &str, + operation: &str, + conflict_handling: &str, + expected_store_token: Value, + draft: Value, +) -> anyhow::Result { + let validation = validate_policy_by_pipe(pipe_name, &draft).await?; let replacement_request = json!({ "RequestKind": "PolicyReplacementRequest", "RequestVersion": "1.0", @@ -542,7 +625,7 @@ async fn replace_policy_response( "ValidationReceipt": validation["ValidationReceipt"] }); let response = request_with_body( - &agent.pipe_name, + pipe_name, "PUT", "/v1/policy", Some("application/json"), @@ -615,13 +698,94 @@ async fn management_write_tokens_survive_watcher_reload(agent_path: &Path) -> an Ok(()) } -async fn standard_user_management(agent_path: &Path) -> anyhow::Result<()> { - let agent = AgentHarness::start_unelevated(agent_path).await?; - let management = policy_management(&agent).await?; +async fn standard_user_server( + agent_path: &Path, + ready_path: &Path, + stop_path: &Path, + nonce: &str, +) -> anyhow::Result<()> { + ensure!(!ready_path.exists(), "standard-user readiness path already exists"); + ensure!(!stop_path.exists(), "standard-user stop path already exists"); + + let mut agent = AgentHarness::start_unelevated(agent_path).await?; + let server = verify_local_system()?; + let agent_pid = agent.child.id().context("Agent process has no PID")?; + let child_sid = process_sid(agent_pid)?; + ensure!( + child_sid == server.sid, + "Agent and test server must both run as LocalSystem" + ); + + let readiness = serde_json::to_vec(&json!({ + "Nonce": nonce, + "PipeName": agent.pipe_name, + "ServerPid": server.pid, + "ServerSid": server.sid.to_string(), + "AgentPid": agent_pid, + "AgentSid": child_sid.to_string(), + }))?; + let mut ready_file = OpenOptions::new() + .write(true) + .create_new(true) + .open(ready_path) + .context("create standard-user readiness file")?; + ready_file + .write_all(&readiness) + .context("write standard-user readiness file")?; + ready_file.sync_all().context("flush standard-user readiness file")?; + + let deadline = Instant::now() + Duration::from_secs(90); + while !stop_path.exists() { + if let Some(status) = agent.child.try_wait().context("query Agent status")? { + bail!("Agent exited during standard-user test with {status}"); + } + ensure!( + Instant::now() < deadline, + "timed out waiting for standard-user client completion" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + wait_for_log(&agent, "Policy management write denied").await +} + +async fn standard_user_management(ready_path: &Path, nonce: &str, client: &ProcessIdentity) -> anyhow::Result<()> { + let readiness: Value = + serde_json::from_slice(&std::fs::read(ready_path).context("read standard-user readiness file")?) + .context("parse standard-user readiness file")?; + ensure!(readiness["Nonce"] == nonce, "standard-user readiness nonce mismatch"); + let pipe_name = readiness["PipeName"] + .as_str() + .context("readiness file has no pipe name")?; + let server_pid = readiness["ServerPid"] + .as_u64() + .and_then(|pid| u32::try_from(pid).ok()) + .context("readiness file has no valid server PID")?; + let agent_pid = readiness["AgentPid"] + .as_u64() + .and_then(|pid| u32::try_from(pid).ok()) + .context("readiness file has no valid Agent PID")?; + let system = Sid::from_well_known(WinLocalSystemSid, None) + .context("construct LocalSystem SID")? + .to_string(); + ensure!( + readiness["ServerSid"] == system && readiness["AgentSid"] == system, + "test server and Agent identities were not recorded as LocalSystem" + ); + ensure!( + client.pid != server_pid && client.pid != agent_pid && server_pid != agent_pid, + "standard-user client, test server, and Agent must be distinct processes" + ); + ensure!( + client.sid.to_string() != system, + "standard-user client unexpectedly uses the server identity" + ); + + let management = policy_management_by_pipe(pipe_name).await?; ensure!(management["State"] == "Missing", "expected a missing policy"); let valid_draft = policy_draft("tests.standard-user", "Test"); - let validation = validate_policy(&agent, &valid_draft).await?; + let validation = validate_policy_by_pipe(pipe_name, &valid_draft).await?; ensure!( validation["CanonicalDraft"].is_object() && validation["ValidationReceipt"].is_string(), "valid draft did not produce a canonical draft and receipt" @@ -635,7 +799,7 @@ async fn standard_user_management(agent_path: &Path) -> anyhow::Result<()> { "Draft": invalid_draft }); let invalid_response = request_with_body( - &agent.pipe_name, + pipe_name, "POST", "/v1/policy/validate", Some("application/json"), @@ -654,8 +818,8 @@ async fn standard_user_management(agent_path: &Path) -> anyhow::Result<()> { "invalid draft returned a canonical draft" ); - let denied = replace_policy_response( - &agent, + let denied = replace_policy_response_by_pipe( + pipe_name, "Create", "Reject", management["StoreToken"].clone(), @@ -671,7 +835,7 @@ async fn standard_user_management(agent_path: &Path) -> anyhow::Result<()> { denied.json()?["Code"] == "AdministratorRequired", "standard-user Create did not require an administrator" ); - wait_for_log(&agent, "Policy management write denied").await + Ok(()) } async fn managed_policy_lifecycle(agent_path: &Path) -> anyhow::Result<()> { From 84d75bdc588848fd10a38a79536649caf2aadf7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 8 Sep 2026 23:25:25 -0400 Subject: [PATCH 04/39] ci(agent): handle detached policy tester launch Treat PsExec's detached-process PID as diagnostic output and use bounded, validated readiness as the authoritative launch result. Publish readiness atomically, preserve launch diagnostics, and keep shutdown and cleanup idempotent so orchestration errors remain actionable. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/agent-policy-tester/run-unelevated.ps1 | 171 ++++++++++++++---- crates/agent-policy-tester/src/windows.rs | 13 +- 2 files changed, 149 insertions(+), 35 deletions(-) diff --git a/crates/agent-policy-tester/run-unelevated.ps1 b/crates/agent-policy-tester/run-unelevated.ps1 index 6508507d3..2ea138f8f 100644 --- a/crates/agent-policy-tester/run-unelevated.ps1 +++ b/crates/agent-policy-tester/run-unelevated.ps1 @@ -1,5 +1,5 @@ param( - [ValidateSet("Orchestrate", "Stage", "Server", "Run", "Signal", "Cleanup")] + [ValidateSet("Orchestrate", "Stage", "Server", "Run", "Signal", "Cleanup", "SelfTest")] [string] $Action = "Orchestrate", [string] $TesterPath, [string] $StagedTesterPath, @@ -15,6 +15,131 @@ param( $ErrorActionPreference = "Stop" +function Test-ExplicitPsExecLaunchFailure { + param([string] $Diagnostics) + + return $Diagnostics -match '(?im)^(Couldn''t install PSEXESVC service:|Error establishing communication with PsExec service|Access is denied\.)' +} + +function Wait-ServerReadiness { + param( + [string] $Path, + [string] $ServerStatusPath, + [string] $ExpectedNonce, + [int] $TimeoutMilliseconds, + [int] $LaunchValue, + [string] $LaunchDiagnostics + ) + + if (Test-ExplicitPsExecLaunchFailure $LaunchDiagnostics) { + throw "LocalSystem test server launch failed (value $LaunchValue): $LaunchDiagnostics" + } + + $deadline = [DateTime]::UtcNow.AddMilliseconds($TimeoutMilliseconds) + while (-not (Test-Path -LiteralPath $Path)) { + if (Test-Path -LiteralPath $ServerStatusPath) { + $status = Get-Content -LiteralPath $ServerStatusPath -Raw + throw "LocalSystem test server exited with status $status before publishing readiness (launch value $LaunchValue): $LaunchDiagnostics" + } + if ([DateTime]::UtcNow -ge $deadline) { + throw "Timed out waiting for LocalSystem test server readiness (launch value $LaunchValue): $LaunchDiagnostics" + } + Start-Sleep -Milliseconds 100 + } + + $readiness = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + if ($readiness.Nonce -cne $ExpectedNonce) { + throw "LocalSystem test server readiness nonce mismatch" + } + if ([string]::IsNullOrWhiteSpace($readiness.PipeName)) { + throw "LocalSystem test server readiness has no pipe name" + } + if ($readiness.ServerPid -le 0 -or $readiness.AgentPid -le 0 -or $readiness.ServerPid -eq $readiness.AgentPid) { + throw "LocalSystem test server readiness has invalid process identities" + } + if ($readiness.ServerSid -cne 'S-1-5-18' -or $readiness.AgentSid -cne 'S-1-5-18') { + throw "LocalSystem test server readiness has non-SYSTEM identities" + } + + return $readiness +} + +function Remove-StagingPath { + param([string] $Path) + + for ($attempt = 0; $attempt -lt 20 -and (Test-Path -LiteralPath $Path); $attempt++) { + try { + Remove-Item -LiteralPath $Path -Recurse -Force + } catch { + if ($attempt -eq 19) { + throw "Failed to remove $Path after 20 attempts: $_" + } + Start-Sleep -Milliseconds 250 + } + } + if (Test-Path -LiteralPath $Path) { + throw "Failed to remove $Path" + } +} + +function Invoke-RunnerSelfTests { + $root = Join-Path ([System.IO.Path]::GetTempPath()) "agent-policy-runner-$([guid]::NewGuid().ToString('N'))" + New-Item -ItemType Directory -Path $root | Out-Null + try { + $ready = Join-Path $root "ready.json" + $status = Join-Path $root "status" + Set-Content -LiteralPath $ready -Value ( + @{ + Nonce = "nonce" + PipeName = "\\.\pipe\test" + ServerPid = 100 + ServerSid = "S-1-5-18" + AgentPid = 200 + AgentSid = "S-1-5-18" + } | ConvertTo-Json -Compress + ) + Wait-ServerReadiness -Path $ready -ServerStatusPath $status -ExpectedNonce "nonce" ` + -TimeoutMilliseconds 50 -LaunchValue 6256 -LaunchDiagnostics "started detached process" | Out-Null + + Remove-Item -LiteralPath $ready + try { + Wait-ServerReadiness -Path $ready -ServerStatusPath $status -ExpectedNonce "nonce" ` + -TimeoutMilliseconds 50 -LaunchValue 6256 -LaunchDiagnostics "started detached process" | Out-Null + throw "Missing-readiness simulation unexpectedly succeeded" + } catch { + if ( + $_ -notmatch "Timed out waiting for LocalSystem test server readiness" -or + $_ -notmatch "started detached process" + ) { + throw + } + } + + try { + Wait-ServerReadiness -Path $ready -ServerStatusPath $status -ExpectedNonce "nonce" ` + -TimeoutMilliseconds 5000 -LaunchValue 6 ` + -LaunchDiagnostics "Couldn't install PSEXESVC service: Access is denied." | Out-Null + throw "Explicit-launch-failure simulation unexpectedly succeeded" + } catch { + if ( + $_ -notmatch "LocalSystem test server launch failed" -or + $_ -notmatch "Couldn't install PSEXESVC service" + ) { + throw + } + } + + Remove-StagingPath -Path (Join-Path $root "already-absent") + } finally { + Remove-StagingPath -Path $root + } +} + +if ($Action -eq "SelfTest") { + Invoke-RunnerSelfTests + exit 0 +} + if ($Action -eq "Run") { $env:TEMP = $TempPath $env:TMP = $TempPath @@ -37,22 +162,15 @@ if ($Action -eq "Server") { } if ($Action -eq "Signal") { - New-Item -ItemType File -Path $StopPath -ErrorAction Stop | Out-Null + if (-not (Test-Path -LiteralPath $StopPath)) { + New-Item -ItemType File -Path $StopPath -ErrorAction Stop | Out-Null + } exit 0 } if ($Action -eq "Cleanup") { - for ($attempt = 0; $attempt -lt 20 -and (Test-Path -LiteralPath $StagingPath); $attempt++) { - try { - Remove-Item -LiteralPath $StagingPath -Recurse -Force - } catch { - if ($attempt -eq 19) { - throw "Failed to remove $StagingPath after 20 attempts: $_" - } - Start-Sleep -Milliseconds 250 - } - } - exit $(if (Test-Path -LiteralPath $StagingPath) { 1 } else { 0 }) + Remove-StagingPath -Path $StagingPath + exit 0 } if ($Action -eq "Stage") { @@ -136,9 +254,10 @@ $serverOutputPath = Join-Path $stagingPath "standard-user-server.out" $tempPath = Join-Path $env:USERPROFILE "AppData\LocalLow\Temp" $nonce = [guid]::NewGuid().ToString("N") $exitCode = 1 -$serverStarted = $false +$coordinationReady = $false try { + Invoke-RunnerSelfTests Set-Content -LiteralPath $outputPath -Value "" New-Item -ItemType Directory -Path $tempPath -Force | Out-Null @@ -155,22 +274,12 @@ try { -StopPath $stopPath -StatusPath $statusPath -ServerOutputPath $serverOutputPath -Nonce $nonce 2>&1 $serverStartExitCode = $LASTEXITCODE $serverOutput | Out-File $outputPath -Append - if ($serverStartExitCode -ne 0) { - throw "LocalSystem test server failed to start with exit code $serverStartExitCode" - } - $serverStarted = $true - - $deadline = [DateTime]::UtcNow.AddSeconds(30) - while (-not (Test-Path -LiteralPath $readyPath)) { - if (Test-Path -LiteralPath $statusPath) { - throw "LocalSystem test server exited before publishing readiness" - } - if ([DateTime]::UtcNow -ge $deadline) { - throw "Timed out waiting for LocalSystem test server readiness" - } - Start-Sleep -Milliseconds 100 - } - Get-Content -LiteralPath $readyPath | Out-File $outputPath -Append + "Detached server launch value: $serverStartExitCode" | Out-File $outputPath -Append + $serverLaunchDiagnostics = ($serverOutput | Out-String).Trim() + $readiness = Wait-ServerReadiness -Path $readyPath -ServerStatusPath $statusPath -ExpectedNonce $nonce ` + -TimeoutMilliseconds 30000 -LaunchValue $serverStartExitCode -LaunchDiagnostics $serverLaunchDiagnostics + $coordinationReady = $true + $readiness | ConvertTo-Json -Compress | Out-File $outputPath -Append $testerOutput = & psexec.exe -accepteula -l pwsh.exe -NoProfile -File $PSCommandPath ` -Action Run -StagedTesterPath $stagedTesterPath -AgentPath $agentPath -TempPath $tempPath ` @@ -181,7 +290,7 @@ try { $_ | Out-File $outputPath -Append $exitCode = 1 } finally { - if ($serverStarted) { + if ($coordinationReady) { $signalOutput = & psexec.exe -accepteula -s pwsh.exe -NoProfile -File $PSCommandPath ` -Action Signal -StopPath $stopPath 2>&1 $signalExitCode = $LASTEXITCODE diff --git a/crates/agent-policy-tester/src/windows.rs b/crates/agent-policy-tester/src/windows.rs index 933904842..6d189cf19 100644 --- a/crates/agent-policy-tester/src/windows.rs +++ b/crates/agent-policy-tester/src/windows.rs @@ -724,15 +724,20 @@ async fn standard_user_server( "AgentPid": agent_pid, "AgentSid": child_sid.to_string(), }))?; + let ready_temp_path = ready_path.with_extension("tmp"); let mut ready_file = OpenOptions::new() .write(true) .create_new(true) - .open(ready_path) - .context("create standard-user readiness file")?; + .open(&ready_temp_path) + .context("create standard-user readiness temporary file")?; ready_file .write_all(&readiness) - .context("write standard-user readiness file")?; - ready_file.sync_all().context("flush standard-user readiness file")?; + .context("write standard-user readiness temporary file")?; + ready_file + .sync_all() + .context("flush standard-user readiness temporary file")?; + drop(ready_file); + std::fs::rename(&ready_temp_path, ready_path).context("publish standard-user readiness file")?; let deadline = Instant::now() + Duration::from_secs(90); while !stop_path.exists() { From 5806e56b92d26c17c55bcbc3024e48693f0aaada Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 9 Sep 2026 00:46:35 -0400 Subject: [PATCH 05/39] test(agent): use medium-integrity policy client Launch policy authorization requests from a unique temporary standard-user account instead of a Low-integrity PsExec token. Require the exact account SID and Medium mandatory integrity level, keep credentials out of arguments and logs, and remove the account and profile after the bounded run. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/agent-policy-tester/run-unelevated.ps1 | 193 +++++++++++++++++- crates/agent-policy-tester/src/windows.rs | 122 ++++++++++- 2 files changed, 296 insertions(+), 19 deletions(-) diff --git a/crates/agent-policy-tester/run-unelevated.ps1 b/crates/agent-policy-tester/run-unelevated.ps1 index 2ea138f8f..8e7a24c4d 100644 --- a/crates/agent-policy-tester/run-unelevated.ps1 +++ b/crates/agent-policy-tester/run-unelevated.ps1 @@ -10,7 +10,8 @@ param( [string] $StopPath, [string] $StatusPath, [string] $ServerOutputPath, - [string] $Nonce + [string] $Nonce, + [string] $ExpectedClientSid ) $ErrorActionPreference = "Stop" @@ -82,6 +83,163 @@ function Remove-StagingPath { } } +function New-RandomSecurePassword { + $alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*" + $bytes = [byte[]]::new(32) + [System.Security.Cryptography.RandomNumberGenerator]::Fill($bytes) + $password = [System.Security.SecureString]::new() + foreach ($byte in $bytes) { + $password.AppendChar($alphabet[$byte % $alphabet.Length]) + } + foreach ($required in "Aa1!".ToCharArray()) { + $password.AppendChar($required) + } + $password.MakeReadOnly() + return $password +} + +function New-StandardUserAccount { + $name = "dgwpol$([guid]::NewGuid().ToString('N').Substring(0, 12))" + $password = New-RandomSecurePassword + try { + $user = New-LocalUser -Name $name -Password $password -AccountNeverExpires ` + -PasswordNeverExpires -UserMayNotChangePassword ` + -Description "Temporary Devolutions Agent policy E2E user" + $usersGroup = Get-LocalGroup -SID "S-1-5-32-545" + $isMember = Get-LocalGroupMember -Group $usersGroup -ErrorAction Stop | + Where-Object { $_.SID.Value -eq $user.SID.Value } + if (-not $isMember) { + Add-LocalGroupMember -Group $usersGroup -Member $user -ErrorAction Stop + } + return [pscustomobject]@{ + Name = $name + Sid = $user.SID.Value + Credential = [System.Management.Automation.PSCredential]::new( + $name, + $password + ) + } + } catch { + Remove-LocalUser -Name $name -ErrorAction SilentlyContinue + $password.Dispose() + throw + } +} + +function Set-StandardUserTempAcl { + param( + [string] $Path, + [string] $UserSid + ) + + New-Item -ItemType Directory -Path $Path -ErrorAction Stop | Out-Null + & icacls.exe $Path /inheritance:r /grant:r ` + '*S-1-5-18:(OI)(CI)(F)' ` + '*S-1-5-32-544:(OI)(CI)(F)' ` + "*$($UserSid):(OI)(CI)(M)" + if ($LASTEXITCODE -ne 0) { + throw "Failed to protect the standard-user temporary directory" + } +} + +function Invoke-StandardUserClient { + param( + [System.Management.Automation.PSCredential] $Credential, + [string] $UserSid, + [string] $ClientTempPath, + [string] $ScriptPath, + [string] $TesterExecutablePath, + [string] $AgentExecutablePath, + [string] $ReadinessPath, + [string] $ExpectedNonce + ) + + $startInfo = [System.Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = (Get-Command pwsh.exe -CommandType Application).Source + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + $startInfo.LoadUserProfile = $false + $startInfo.UserName = $Credential.UserName + $startInfo.Domain = "." + $startInfo.Password = $Credential.Password + $startInfo.WorkingDirectory = $ClientTempPath + $startInfo.Environment["TEMP"] = $ClientTempPath + $startInfo.Environment["TMP"] = $ClientTempPath + foreach ($argument in @( + "-NoProfile", + "-File", + $ScriptPath, + "-Action", + "Run", + "-StagedTesterPath", + $TesterExecutablePath, + "-AgentPath", + $AgentExecutablePath, + "-TempPath", + $ClientTempPath, + "-ReadyPath", + $ReadinessPath, + "-Nonce", + $ExpectedNonce, + "-ExpectedClientSid", + $UserSid + )) { + $startInfo.ArgumentList.Add($argument) + } + + $process = [System.Diagnostics.Process]::new() + $process.StartInfo = $startInfo + try { + if (-not $process.Start()) { + throw "Failed to start the medium-integrity standard-user client" + } + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit(60000)) { + $process.Kill($true) + $process.WaitForExit() + throw "Timed out waiting for the medium-integrity standard-user client" + } + return [pscustomobject]@{ + ExitCode = $process.ExitCode + StdOut = $stdout.GetAwaiter().GetResult() + StdErr = $stderr.GetAwaiter().GetResult() + } + } finally { + $process.Dispose() + } +} + +function Remove-StandardUserAccount { + param( + [string] $Name, + [string] $Sid + ) + + for ($attempt = 0; $attempt -lt 20; $attempt++) { + try { + $profile = Get-CimInstance -ClassName Win32_UserProfile -Filter "SID='$Sid'" -ErrorAction Stop + if ($profile) { + $profile | Remove-CimInstance -ErrorAction Stop + } + if (Get-LocalUser -Name $Name -ErrorAction SilentlyContinue) { + Remove-LocalUser -Name $Name -ErrorAction Stop + } + if (-not (Get-LocalUser -Name $Name -ErrorAction SilentlyContinue)) { + return + } + } catch { + if ($attempt -eq 19) { + throw + } + } + Start-Sleep -Milliseconds 250 + } + throw "Temporary standard-user account still exists after 20 removal attempts" +} + function Invoke-RunnerSelfTests { $root = Join-Path ([System.IO.Path]::GetTempPath()) "agent-policy-runner-$([guid]::NewGuid().ToString('N'))" New-Item -ItemType Directory -Path $root | Out-Null @@ -143,7 +301,7 @@ if ($Action -eq "SelfTest") { if ($Action -eq "Run") { $env:TEMP = $TempPath $env:TMP = $TempPath - & $StagedTesterPath $AgentPath standard-client $ReadyPath $Nonce + & $StagedTesterPath $AgentPath standard-client $ExpectedClientSid $ReadyPath $Nonce exit $LASTEXITCODE } @@ -251,15 +409,14 @@ $readyPath = Join-Path $stagingPath "standard-user-ready.json" $stopPath = Join-Path $stagingPath "standard-user-stop" $statusPath = Join-Path $stagingPath "standard-user-server.status" $serverOutputPath = Join-Path $stagingPath "standard-user-server.out" -$tempPath = Join-Path $env:USERPROFILE "AppData\LocalLow\Temp" $nonce = [guid]::NewGuid().ToString("N") $exitCode = 1 $coordinationReady = $false +$clientAccount = $null try { Invoke-RunnerSelfTests Set-Content -LiteralPath $outputPath -Value "" - New-Item -ItemType Directory -Path $tempPath -Force | Out-Null $stageOutput = & psexec.exe -accepteula -s pwsh.exe -NoProfile -File $PSCommandPath ` -Action Stage -TesterPath $testerPath -StagedTesterPath $stagedTesterPath -StagingPath $stagingPath 2>&1 @@ -269,6 +426,10 @@ try { throw "LocalSystem tester staging failed with exit code $stageExitCode" } + $clientAccount = New-StandardUserAccount + $clientTempPath = Join-Path $stagingPath "standard-user-temp" + Set-StandardUserTempAcl -Path $clientTempPath -UserSid $clientAccount.Sid + $serverOutput = & psexec.exe -accepteula -s -d pwsh.exe -NoProfile -File $PSCommandPath ` -Action Server -StagedTesterPath $stagedTesterPath -AgentPath $agentPath -ReadyPath $readyPath ` -StopPath $stopPath -StatusPath $statusPath -ServerOutputPath $serverOutputPath -Nonce $nonce 2>&1 @@ -281,11 +442,12 @@ try { $coordinationReady = $true $readiness | ConvertTo-Json -Compress | Out-File $outputPath -Append - $testerOutput = & psexec.exe -accepteula -l pwsh.exe -NoProfile -File $PSCommandPath ` - -Action Run -StagedTesterPath $stagedTesterPath -AgentPath $agentPath -TempPath $tempPath ` - -ReadyPath $readyPath -Nonce $nonce 2>&1 - $exitCode = $LASTEXITCODE - $testerOutput | Out-File $outputPath -Append + $client = Invoke-StandardUserClient -Credential $clientAccount.Credential -UserSid $clientAccount.Sid ` + -ClientTempPath $clientTempPath -ScriptPath $PSCommandPath -TesterExecutablePath $stagedTesterPath ` + -AgentExecutablePath $agentPath -ReadinessPath $readyPath -ExpectedNonce $nonce + $client.StdOut | Out-File $outputPath -Append + $client.StdErr | Out-File $outputPath -Append + $exitCode = $client.ExitCode } catch { $_ | Out-File $outputPath -Append $exitCode = 1 @@ -317,6 +479,19 @@ try { } } + if ($clientAccount) { + try { + Remove-StandardUserAccount -Name $clientAccount.Name -Sid $clientAccount.Sid + } catch { + $_ | Out-File $outputPath -Append + if ($exitCode -eq 0) { + $exitCode = 1 + } + } finally { + $clientAccount.Credential.Password.Dispose() + } + } + $cleanupOutput = & psexec.exe -accepteula -s pwsh.exe -NoProfile -File $PSCommandPath ` -Action Cleanup -StagingPath $stagingPath 2>&1 $cleanupExitCode = $LASTEXITCODE diff --git a/crates/agent-policy-tester/src/windows.rs b/crates/agent-policy-tester/src/windows.rs index 6d189cf19..8dcb471ad 100644 --- a/crates/agent-policy-tester/src/windows.rs +++ b/crates/agent-policy-tester/src/windows.rs @@ -1,5 +1,6 @@ use std::fs::OpenOptions; use std::io::Write as _; +use std::mem::size_of; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::time::{Duration, Instant}; @@ -10,13 +11,20 @@ use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; use tokio::net::windows::named_pipe::ClientOptions; use win_api_wrappers::identity::sid::Sid; use win_api_wrappers::process::Process; -use windows::Win32::Security::{TOKEN_DUPLICATE, TOKEN_QUERY, WinBuiltinAdministratorsSid, WinLocalSystemSid}; -use windows::Win32::System::Threading::PROCESS_QUERY_LIMITED_INFORMATION; +use windows::Win32::Foundation::{CloseHandle, HANDLE}; +use windows::Win32::Security::{ + GetSidSubAuthority, GetSidSubAuthorityCount, GetTokenInformation, TOKEN_DUPLICATE, TOKEN_MANDATORY_LABEL, + TOKEN_QUERY, TokenIntegrityLevel, WinBuiltinAdministratorsSid, WinLocalSystemSid, +}; +use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken, PROCESS_QUERY_LIMITED_INFORMATION}; const FULL_POLICY: &str = include_str!("../../now-package-broker/src/assets/samples/corporate-allowlist.policy.json"); const MANAGED_POLICY_RELATIVE_PATH: &str = r"Devolutions\PackageBroker\package-broker-policy.json"; const MANAGED_AUTHORITY_MARKER: &str = r"Devolutions\PackageBroker\.package-broker-managed-authority.v1"; const LEGACY_POLICY_RELATIVE_PATH: &str = r"Devolutions\Agent\package-broker-policy.json"; +#[cfg(test)] +const SECURITY_MANDATORY_LOW_RID: u32 = 0x1000; +const SECURITY_MANDATORY_MEDIUM_RID: u32 = 0x2000; struct AgentHarness { child: tokio::process::Child, @@ -221,7 +229,8 @@ pub(crate) async fn run() -> anyhow::Result<()> { standard_user_server(&agent_path, &ready_path, &stop_path, &nonce).await?; } Mode::StandardClient => { - let client = verify_standard_user()?; + let expected_sid = next_string(&mut args, "expected client SID")?; + let client = verify_standard_user(&expected_sid)?; let ready_path = next_path(&mut args, "ready path")?; let nonce = next_string(&mut args, "coordination nonce")?; ensure!(args.next().is_none(), "unexpected standard-client arguments"); @@ -276,18 +285,85 @@ fn current_process_identity() -> anyhow::Result<(ProcessIdentity, bool, bool)> { )) } -fn verify_standard_user() -> anyhow::Result { +fn verify_standard_user(expected_sid: &str) -> anyhow::Result { let (identity, is_administrator, _) = current_process_identity()?; - let system = Sid::from_well_known(WinLocalSystemSid, None).context("construct LocalSystem SID")?; - ensure!( - identity.sid != system, - "standard-client mode requires a non-SYSTEM account" - ); + validate_standard_user_token( + &identity.sid.to_string(), + expected_sid, + is_administrator, + current_integrity_level()?, + )?; + Ok(identity) +} + +fn validate_standard_user_token( + actual_sid: &str, + expected_sid: &str, + is_administrator: bool, + integrity_level: u32, +) -> anyhow::Result<()> { + ensure!(actual_sid == expected_sid, "standard-client account SID mismatch"); ensure!( !is_administrator, "standard-client mode requires disabled Administrators membership" ); - Ok(identity) + ensure!( + integrity_level == SECURITY_MANDATORY_MEDIUM_RID, + "standard-client mode requires Medium integrity, got RID {integrity_level:#x}" + ); + Ok(()) +} + +fn current_integrity_level() -> anyhow::Result { + let mut token = HANDLE::default(); + // SAFETY: `GetCurrentProcess` has no preconditions and returns a process pseudo-handle. + let process = unsafe { GetCurrentProcess() }; + // SAFETY: The process pseudo-handle is valid and `token` is a writable output parameter. + unsafe { + OpenProcessToken(process, TOKEN_QUERY, &mut token).context("open current process integrity token")?; + } + let result = integrity_level(token); + // SAFETY: `OpenProcessToken` returned this owned token handle. + unsafe { + CloseHandle(token).context("close current process integrity token")?; + } + result +} + +fn integrity_level(token: HANDLE) -> anyhow::Result { + let mut length = 0; + // SAFETY: A null output buffer with length zero is the documented size query. + let _ = unsafe { GetTokenInformation(token, TokenIntegrityLevel, None, 0, &mut length) }; + ensure!( + usize::try_from(length)? >= size_of::(), + "TokenIntegrityLevel returned an undersized buffer" + ); + + let word_count = usize::try_from(length)?.div_ceil(size_of::()); + let mut buffer = vec![0usize; word_count]; + // SAFETY: The aligned buffer is writable for `length` bytes and the token handle is valid. + unsafe { + GetTokenInformation( + token, + TokenIntegrityLevel, + Some(buffer.as_mut_ptr().cast()), + length, + &mut length, + ) + .context("query token integrity level")?; + } + // SAFETY: A successful TokenIntegrityLevel query initialized a TOKEN_MANDATORY_LABEL. + let label = unsafe { &*buffer.as_ptr().cast::() }; + // SAFETY: The returned token label contains a valid SID. + let sub_authority_count_ptr = unsafe { GetSidSubAuthorityCount(label.Label.Sid) }; + // SAFETY: `GetSidSubAuthorityCount` returns a pointer into the valid label SID. + let sub_authority_count = unsafe { *sub_authority_count_ptr }; + ensure!(sub_authority_count > 0, "integrity SID has no sub-authority"); + // SAFETY: The index is within the validated SID sub-authority count. + let rid_ptr = unsafe { GetSidSubAuthority(label.Label.Sid, u32::from(sub_authority_count - 1)) }; + // SAFETY: `GetSidSubAuthority` returns a pointer into the valid label SID. + let rid = unsafe { *rid_ptr }; + Ok(rid) } fn verify_local_system() -> anyhow::Result { @@ -1173,6 +1249,7 @@ async fn complete_snapshots_across_reload(agent_path: &Path) -> anyhow::Result<( if policy == &full { break; } + ensure!(Instant::now() < deadline, "agent did not reload the policy"); tokio::task::yield_now().await; } @@ -1188,3 +1265,28 @@ async fn complete_snapshots_across_reload(agent_path: &Path) -> anyhow::Result<( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn standard_user_token_requires_medium_integrity() { + validate_standard_user_token( + "S-1-5-21-1-2-3-1001", + "S-1-5-21-1-2-3-1001", + false, + SECURITY_MANDATORY_MEDIUM_RID, + ) + .expect("matching standard-user SID at Medium integrity is valid"); + + let error = validate_standard_user_token( + "S-1-5-21-1-2-3-1001", + "S-1-5-21-1-2-3-1001", + false, + SECURITY_MANDATORY_LOW_RID, + ) + .expect_err("Low integrity must not satisfy the standard-user scenario"); + assert!(error.to_string().contains("requires Medium integrity")); + } +} From 116df8405c31d54a25d9919c8fc875ff7611eefa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 9 Sep 2026 01:16:00 -0400 Subject: [PATCH 06/39] ci(agent): stop policy server after launch failures Own LocalSystem test-server shutdown as soon as detached launch is attempted, even when readiness validation fails. Signal the unique protected stop marker with a direct fallback, preserve the original scenario error, and bound status collection before cleanup. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/agent-policy-tester/run-unelevated.ps1 | 103 ++++++++++++++---- 1 file changed, 84 insertions(+), 19 deletions(-) diff --git a/crates/agent-policy-tester/run-unelevated.ps1 b/crates/agent-policy-tester/run-unelevated.ps1 index 8e7a24c4d..e0afd9318 100644 --- a/crates/agent-policy-tester/run-unelevated.ps1 +++ b/crates/agent-policy-tester/run-unelevated.ps1 @@ -287,6 +287,58 @@ function Invoke-RunnerSelfTests { } } + $launchAttempted = $true + Set-Content -LiteralPath $ready -Value ( + @{ + Nonce = "wrong-nonce" + PipeName = "\\.\pipe\test" + ServerPid = 100 + ServerSid = "S-1-5-18" + AgentPid = 200 + AgentSid = "S-1-5-18" + } | ConvertTo-Json -Compress + ) + try { + Wait-ServerReadiness -Path $ready -ServerStatusPath $status -ExpectedNonce "nonce" ` + -TimeoutMilliseconds 50 -LaunchValue 6256 -LaunchDiagnostics "started detached process" | Out-Null + throw "Mismatched-readiness simulation unexpectedly succeeded" + } catch { + if ($_ -notmatch "readiness nonce mismatch" -or -not $launchAttempted) { + throw + } + } + + Set-Content -LiteralPath $ready -Value ( + @{ + Nonce = "nonce" + PipeName = "\\.\pipe\test" + ServerPid = 100 + ServerSid = "S-1-5-18" + AgentPid = 100 + AgentSid = "S-1-5-18" + } | ConvertTo-Json -Compress + ) + try { + Wait-ServerReadiness -Path $ready -ServerStatusPath $status -ExpectedNonce "nonce" ` + -TimeoutMilliseconds 50 -LaunchValue 6256 -LaunchDiagnostics "started detached process" | Out-Null + throw "Invalid-PID readiness simulation unexpectedly succeeded" + } catch { + if ($_ -notmatch "readiness has invalid process identities" -or -not $launchAttempted) { + throw + } + } + + Remove-Item -LiteralPath $ready + try { + Wait-ServerReadiness -Path $ready -ServerStatusPath $status -ExpectedNonce "nonce" ` + -TimeoutMilliseconds 50 -LaunchValue 6256 -LaunchDiagnostics "started detached process" | Out-Null + throw "Attempted-launch missing-readiness simulation unexpectedly succeeded" + } catch { + if ($_ -notmatch "Timed out waiting for LocalSystem test server readiness" -or -not $launchAttempted) { + throw + } + } + Remove-StagingPath -Path (Join-Path $root "already-absent") } finally { Remove-StagingPath -Path $root @@ -411,7 +463,8 @@ $statusPath = Join-Path $stagingPath "standard-user-server.status" $serverOutputPath = Join-Path $stagingPath "standard-user-server.out" $nonce = [guid]::NewGuid().ToString("N") $exitCode = 1 -$coordinationReady = $false +$serverLaunchAttempted = $false +$serverLaunchExplicitlyFailed = $false $clientAccount = $null try { @@ -430,6 +483,7 @@ try { $clientTempPath = Join-Path $stagingPath "standard-user-temp" Set-StandardUserTempAcl -Path $clientTempPath -UserSid $clientAccount.Sid + $serverLaunchAttempted = $true $serverOutput = & psexec.exe -accepteula -s -d pwsh.exe -NoProfile -File $PSCommandPath ` -Action Server -StagedTesterPath $stagedTesterPath -AgentPath $agentPath -ReadyPath $readyPath ` -StopPath $stopPath -StatusPath $statusPath -ServerOutputPath $serverOutputPath -Nonce $nonce 2>&1 @@ -437,9 +491,9 @@ try { $serverOutput | Out-File $outputPath -Append "Detached server launch value: $serverStartExitCode" | Out-File $outputPath -Append $serverLaunchDiagnostics = ($serverOutput | Out-String).Trim() + $serverLaunchExplicitlyFailed = Test-ExplicitPsExecLaunchFailure $serverLaunchDiagnostics $readiness = Wait-ServerReadiness -Path $readyPath -ServerStatusPath $statusPath -ExpectedNonce $nonce ` -TimeoutMilliseconds 30000 -LaunchValue $serverStartExitCode -LaunchDiagnostics $serverLaunchDiagnostics - $coordinationReady = $true $readiness | ConvertTo-Json -Compress | Out-File $outputPath -Append $client = Invoke-StandardUserClient -Credential $clientAccount.Credential -UserSid $clientAccount.Sid ` @@ -452,30 +506,41 @@ try { $_ | Out-File $outputPath -Append $exitCode = 1 } finally { - if ($coordinationReady) { + if ($serverLaunchAttempted) { $signalOutput = & psexec.exe -accepteula -s pwsh.exe -NoProfile -File $PSCommandPath ` -Action Signal -StopPath $stopPath 2>&1 $signalExitCode = $LASTEXITCODE $signalOutput | Out-File $outputPath -Append - if ($signalExitCode -ne 0 -and $exitCode -eq 0) { - $exitCode = $signalExitCode - } - - $deadline = [DateTime]::UtcNow.AddSeconds(30) - while (-not (Test-Path -LiteralPath $statusPath) -and [DateTime]::UtcNow -lt $deadline) { - Start-Sleep -Milliseconds 100 + if ($signalExitCode -ne 0 -and -not (Test-Path -LiteralPath $stopPath)) { + try { + New-Item -ItemType File -Path $stopPath -ErrorAction Stop | Out-Null + "Created the stop marker directly after SYSTEM signaling failed" | Out-File $outputPath -Append + } catch { + $_ | Out-File $outputPath -Append + } } - if (Test-Path -LiteralPath $serverOutputPath) { - Get-Content -LiteralPath $serverOutputPath | Out-File $outputPath -Append + if (-not (Test-Path -LiteralPath $stopPath) -and $exitCode -eq 0) { + "Failed to create the LocalSystem test server stop marker" | Out-File $outputPath -Append + $exitCode = 1 } - if (Test-Path -LiteralPath $statusPath) { - $serverExitCode = [int](Get-Content -LiteralPath $statusPath -Raw) - if ($serverExitCode -ne 0 -and $exitCode -eq 0) { - $exitCode = $serverExitCode + + if (-not $serverLaunchExplicitlyFailed) { + $deadline = [DateTime]::UtcNow.AddSeconds(30) + while (-not (Test-Path -LiteralPath $statusPath) -and [DateTime]::UtcNow -lt $deadline) { + Start-Sleep -Milliseconds 100 + } + if (Test-Path -LiteralPath $serverOutputPath) { + Get-Content -LiteralPath $serverOutputPath | Out-File $outputPath -Append + } + if (Test-Path -LiteralPath $statusPath) { + $serverExitCode = [int](Get-Content -LiteralPath $statusPath -Raw) + if ($serverExitCode -ne 0 -and $exitCode -eq 0) { + $exitCode = $serverExitCode + } + } elseif ($exitCode -eq 0) { + "Timed out waiting for LocalSystem test server shutdown" | Out-File $outputPath -Append + $exitCode = 1 } - } elseif ($exitCode -eq 0) { - "Timed out waiting for LocalSystem test server shutdown" | Out-File $outputPath -Append - $exitCode = 1 } } From dafdb88bf98725d0a25a96a784409880a5800330 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 16 Sep 2026 02:32:22 +0900 Subject: [PATCH 07/39] test(agent): exercise revised policy lifecycle Cover the current policy contract, receipt invalidation, warnings, and interrupted Repair recovery. Exercise actual installer conversion and managed authority under the hosted LocalSystem gate without accepting skipped privileged tests or incomplete server completion status. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 27 +- crates/agent-policy-tester/run-as-system.ps1 | 41 +- .../run-installer-tests.ps1 | 74 +++ crates/agent-policy-tester/run-unelevated.ps1 | 59 ++- crates/agent-policy-tester/src/windows.rs | 288 ++++++++++-- .../InstalledAgentMigrationE2eTests.cs | 438 ++++++++++++++++++ 6 files changed, 885 insertions(+), 42 deletions(-) create mode 100644 crates/agent-policy-tester/run-installer-tests.ps1 create mode 100644 package/AgentWindowsManaged.Tests/InstalledAgentMigrationE2eTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cecf7f4af..1fe34617e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1385,12 +1385,14 @@ jobs: name: Agent policy end-to-end test runs-on: windows-2022 needs: [preflight] + env: + AGENT_POLICY_TEST_SHA: ${{ inputs.ref || github.event.pull_request.head.sha || needs.preflight.outputs.ref }} steps: - name: Checkout ${{ github.repository }} uses: actions/checkout@v6 with: - ref: ${{ needs.preflight.outputs.ref }} + ref: ${{ env.AGENT_POLICY_TEST_SHA }} - name: Setup Rust cache uses: ./.github/actions/setup-rust-cache @@ -1413,8 +1415,13 @@ jobs: Add-Content -Path $env:GITHUB_PATH -Value $toolsDir - name: Build Agent policy test executables + id: build-policy-executables shell: pwsh run: | + $actualCommit = git rev-parse HEAD + if ($LASTEXITCODE -ne 0 -or $actualCommit -ne $env:AGENT_POLICY_TEST_SHA) { + throw "Agent policy tests must build the requested commit $env:AGENT_POLICY_TEST_SHA, got $actualCommit" + } cargo build --locked -p devolutions-agent --features dev-skip-broker-signature if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE @@ -1424,6 +1431,11 @@ jobs: exit $LASTEXITCODE } + - name: Build installer tests before entering LocalSystem + id: build-installer-tests + shell: pwsh + run: dotnet build package/AgentWindowsManaged.Tests/DevolutionsAgent.Installer.Tests.csproj --configuration Debug --framework net48 + - name: Run Agent policy tester as standard user shell: pwsh run: | @@ -1435,16 +1447,27 @@ jobs: } - name: Run Agent policy tester as LocalSystem + if: ${{ !cancelled() && steps.build-policy-executables.outcome == 'success' && steps.build-installer-tests.outcome == 'success' }} shell: pwsh run: | $scriptPath = Resolve-Path -Path "./crates/agent-policy-tester/run-as-system.ps1" - psexec -accepteula -s pwsh.exe -NoProfile -File $scriptPath + $dotnetPath = (Get-Command dotnet.exe).Source + psexec -accepteula -s pwsh.exe -NoProfile -File $scriptPath -DotnetPath $dotnetPath $exitCode = $LASTEXITCODE Get-Content -Path ./crates/agent-policy-tester/agent-policy-tester.out if ($exitCode -ne 0) { exit $exitCode } + - name: Upload SYSTEM installer test results + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: agent-policy-installer-system-results + path: | + crates/agent-policy-tester/installer-test-results/*.trx + crates/agent-policy-tester/agent-policy-tester.out + - name: Run policy route authorization tests shell: pwsh run: cargo test --locked -p now-package-broker --features dev-skip-broker-signature diff --git a/crates/agent-policy-tester/run-as-system.ps1 b/crates/agent-policy-tester/run-as-system.ps1 index 14e9028e5..2e23d612f 100644 --- a/crates/agent-policy-tester/run-as-system.ps1 +++ b/crates/agent-policy-tester/run-as-system.ps1 @@ -1,14 +1,32 @@ +param( + [string] $DotnetPath = (Join-Path $env:ProgramFiles "dotnet\dotnet.exe") +) + $ErrorActionPreference = "Stop" $workspacePath = (Resolve-Path (Join-Path $PSScriptRoot "../..")).Path $testerPath = Join-Path $workspacePath "target/debug/agent-policy-tester.exe" $agentPath = Join-Path $workspacePath "target/debug/devolutions-agent.exe" $outputPath = Join-Path $PSScriptRoot "agent-policy-tester.out" -$stagingPath = Join-Path $env:ProgramData "dgw-agent-policy-tester-$([guid]::NewGuid().ToString('N'))" +$stagingPath = Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) "dgw-agent-policy-tester-$([guid]::NewGuid().ToString('N'))" $stagedTesterPath = Join-Path $stagingPath "agent-policy-tester.exe" +$stagedAgentPath = Join-Path $stagingPath "devolutions-agent.exe" +$installerProject = Join-Path $workspacePath "package\AgentWindowsManaged.Tests\DevolutionsAgent.Installer.Tests.csproj" +$installerOutput = Join-Path $workspacePath "package\AgentWindowsManaged.Tests\bin\Debug\net48" +$stagedInstallerOutput = Join-Path $stagingPath "installer-tests" +$resultsPath = Join-Path $PSScriptRoot "installer-test-results" +$exitCode = 1 +$previousTemp = $env:TEMP +$previousTmp = $env:TMP try { Set-Content -LiteralPath $outputPath -Value "" + if (-not [System.Security.Principal.WindowsIdentity]::GetCurrent().IsSystem) { + throw "This runner requires LocalSystem" + } + if ([System.IO.DriveInfo]::new([System.IO.Path]::GetPathRoot($workspacePath)).DriveType -ne 'Fixed') { + throw "Use a local fixed-volume workspace path visible to LocalSystem, not a mapped drive" + } Add-Type -TypeDefinition @' using System; using System.ComponentModel; @@ -61,11 +79,16 @@ public static class AgentPolicyTesterNativeDirectory if (Get-ChildItem -LiteralPath $stagingPath -Force) { throw "The atomically protected staged tester directory was not empty" } + $env:TEMP = Join-Path $stagingPath "scratch" + $env:TMP = $env:TEMP + New-Item -ItemType Directory -Path $env:TEMP | Out-Null Copy-Item -LiteralPath $testerPath -Destination $stagedTesterPath - & icacls.exe $stagedTesterPath /setowner '*S-1-5-18' 2>&1 | Out-File $outputPath -Append + Copy-Item -LiteralPath $agentPath -Destination $stagedAgentPath + Copy-Item -LiteralPath $installerOutput -Destination $stagedInstallerOutput -Recurse + & icacls.exe $stagingPath /setowner '*S-1-5-18' /T /Q 2>&1 | Out-File $outputPath -Append if ($LASTEXITCODE -ne 0) { - throw "Failed to set the staged tester owner" + throw "Failed to set the staged executable and installer test owners" } & icacls.exe $stagedTesterPath /inheritance:r /grant:r '*S-1-5-18:(F)' '*S-1-5-32-544:(F)' 2>&1 | Out-File $outputPath -Append @@ -76,18 +99,28 @@ public static class AgentPolicyTesterNativeDirectory "Staged policy tester at $stagedTesterPath" | Out-File $outputPath -Append Get-Acl -LiteralPath $stagingPath | Format-List Owner, Sddl | Out-File $outputPath -Append Get-Acl -LiteralPath $stagedTesterPath | Format-List Owner, Sddl | Out-File $outputPath -Append - & $stagedTesterPath $agentPath elevated 2>&1 | Out-File $outputPath -Append + & $stagedTesterPath $stagedAgentPath elevated 2>&1 | Out-File $outputPath -Append $exitCode = $LASTEXITCODE + & (Join-Path $PSScriptRoot "run-installer-tests.ps1") ` + -ProjectPath $installerProject -TestOutputPath $stagedInstallerOutput ` + -AgentPath $stagedAgentPath -ResultsPath $resultsPath -DotnetPath $DotnetPath ` + 2>&1 | Out-File $outputPath -Append + if ($LASTEXITCODE -ne 0) { + $exitCode = $LASTEXITCODE + } } catch { $_ | Out-File $outputPath -Append $exitCode = 1 } finally { + $env:TEMP = $previousTemp + $env:TMP = $previousTmp for ($attempt = 0; $attempt -lt 20 -and (Test-Path -LiteralPath $stagingPath); $attempt++) { try { Remove-Item -LiteralPath $stagingPath -Recurse -Force } catch { if ($attempt -eq 19) { "Failed to remove $stagingPath after 20 attempts: $_" | Out-File $outputPath -Append + $exitCode = 1 } else { Start-Sleep -Milliseconds 250 } diff --git a/crates/agent-policy-tester/run-installer-tests.ps1 b/crates/agent-policy-tester/run-installer-tests.ps1 new file mode 100644 index 000000000..e2c67c402 --- /dev/null +++ b/crates/agent-policy-tester/run-installer-tests.ps1 @@ -0,0 +1,74 @@ +param( + [Parameter(Mandatory)] [string] $ProjectPath, + [Parameter(Mandatory)] [string] $TestOutputPath, + [Parameter(Mandatory)] [string] $AgentPath, + [Parameter(Mandatory)] [string] $ResultsPath, + [Parameter(Mandatory)] [string] $DotnetPath +) + +$ErrorActionPreference = "Stop" +$exitCode = 1 +$previousAgent = $env:DEVOLUTIONS_AGENT_MIGRATION_TEST_EXE + +try { + if (-not [System.Security.Principal.WindowsIdentity]::GetCurrent().IsSystem) { + throw "Installer transaction tests must execute as LocalSystem" + } + $testClass = 'DevolutionsAgent.Installer.Tests.PackageBrokerInstallerTests' + $expectedTests = @( + "$testClass.TransactionTestsRunAsLocalSystem" + "$testClass.InstalledAgentConverterUsesAuthoritativeContractBeforePublication" + "$testClass.ConvertedMigrationCommitAndRepeatPreserveOriginalAndEvidence" + "$testClass.RollbackRestoresLegacyArbitrationAndMigrationCanRepeat" + "$testClass.InterruptedPublicationRecoversOnlyOwnedAuthority" + "$testClass.InvalidLegacyPolicyFailsUpgradeAndPreservesSource" + "$testClass.ExistingNewDestinationAndPublicationCollisionArePreserved" + "$testClass.ChangedSourcePreventsDestructiveRollback" + "$testClass.PreexistingAuthorityPreventsLegacyResurrection" + "$testClass.AuthorityCollisionCannotCommitSourceDeletion" + "$testClass.DestinationCollisionCannotCommitSourceDeletion" + "$testClass.UnchangedInputRollbackPreservesLastSurvivingPolicy" + 'DevolutionsAgent.Installer.Tests.InstalledAgentMigrationE2eTests.ConvertedTransactionActivatesAndRetainsManagedAuthority' + ) + $filter = ($expectedTests | ForEach-Object { "FullyQualifiedName=$_" }) -join '|' + New-Item -ItemType Directory -Path $ResultsPath -Force | Out-Null + $trxPath = Join-Path $ResultsPath "installer-system.trx" + if (Test-Path -LiteralPath $trxPath) { + Remove-Item -LiteralPath $trxPath -Force + } + $env:DEVOLUTIONS_AGENT_MIGRATION_TEST_EXE = (Resolve-Path -LiteralPath $AgentPath).Path + & $DotnetPath test $ProjectPath --no-build --no-restore --configuration Debug --framework net48 ` + "-p:OutputPath=$TestOutputPath\" -p:AppendTargetFrameworkToOutputPath=false ` + --filter $filter --logger "trx;LogFileName=installer-system.trx" --results-directory $ResultsPath + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0) { + throw "SYSTEM installer tests exited with $exitCode" + } + [xml] $trx = Get-Content -LiteralPath $trxPath -Raw + $counters = $trx.TestRun.ResultSummary.Counters + $results = @($trx.TestRun.Results.UnitTestResult) + if ($trx.TestRun.ResultSummary.outcome -ne 'Completed' -or + [int] $counters.total -ne $expectedTests.Count -or + [int] $counters.executed -ne $expectedTests.Count -or + [int] $counters.passed -ne $expectedTests.Count -or + [int] $counters.notExecuted -ne 0 -or + $results.Count -ne $expectedTests.Count) { + throw "Expected exactly $($expectedTests.Count) executed, passed SYSTEM installer tests and zero skips: $($counters.OuterXml)" + } + foreach ($name in $expectedTests) { + $matching = @($results | Where-Object { $_.testName -eq $name }) + if ($matching.Count -ne 1 -or $matching[0].outcome -ne 'Passed') { + throw "Required SYSTEM installer test did not pass exactly once: $name" + } + } + Write-Output "Verified all 12 installer SystemFacts and the installed-Agent migration E2E: 13 passed, zero skipped" +} catch { + Write-Output $_ + if ($exitCode -eq 0) { + $exitCode = 1 + } +} finally { + $env:DEVOLUTIONS_AGENT_MIGRATION_TEST_EXE = $previousAgent +} + +exit $exitCode diff --git a/crates/agent-policy-tester/run-unelevated.ps1 b/crates/agent-policy-tester/run-unelevated.ps1 index e0afd9318..5dc9b2b2b 100644 --- a/crates/agent-policy-tester/run-unelevated.ps1 +++ b/crates/agent-policy-tester/run-unelevated.ps1 @@ -22,6 +22,31 @@ function Test-ExplicitPsExecLaunchFailure { return $Diagnostics -match '(?im)^(Couldn''t install PSEXESVC service:|Error establishing communication with PsExec service|Access is denied\.)' } +function Publish-ServerStatus { + param([string] $Path, [int] $ExitCode) + + $temporaryPath = "$Path.$([guid]::NewGuid().ToString('N')).tmp" + try { + [System.IO.File]::WriteAllText($temporaryPath, $ExitCode.ToString([System.Globalization.CultureInfo]::InvariantCulture)) + [System.IO.File]::Move($temporaryPath, $Path) + } finally { + if (Test-Path -LiteralPath $temporaryPath) { + Remove-Item -LiteralPath $temporaryPath -Force + } + } +} + +function Read-ServerStatus { + param([string] $Path) + + $text = [System.IO.File]::ReadAllText($Path) + $value = 0 + if ($text -notmatch '^-?[0-9]+$' -or -not [int]::TryParse($text, [ref] $value)) { + throw "LocalSystem test server published an invalid completion status" + } + return $value +} + function Wait-ServerReadiness { param( [string] $Path, @@ -39,7 +64,7 @@ function Wait-ServerReadiness { $deadline = [DateTime]::UtcNow.AddMilliseconds($TimeoutMilliseconds) while (-not (Test-Path -LiteralPath $Path)) { if (Test-Path -LiteralPath $ServerStatusPath) { - $status = Get-Content -LiteralPath $ServerStatusPath -Raw + $status = Read-ServerStatus -Path $ServerStatusPath throw "LocalSystem test server exited with status $status before publishing readiness (launch value $LaunchValue): $LaunchDiagnostics" } if ([DateTime]::UtcNow -ge $deadline) { @@ -246,6 +271,25 @@ function Invoke-RunnerSelfTests { try { $ready = Join-Path $root "ready.json" $status = Join-Path $root "status" + foreach ($expected in @(0, 1, -1)) { + Publish-ServerStatus -Path $status -ExitCode $expected + if ((Read-ServerStatus -Path $status) -ne $expected) { + throw "Completion-status round trip failed" + } + Remove-Item -LiteralPath $status + } + foreach ($invalid in @("", " ", "failed", "2147483648", "0`n1")) { + [System.IO.File]::WriteAllText($status, $invalid) + try { + Read-ServerStatus -Path $status | Out-Null + throw "Invalid completion status unexpectedly succeeded" + } catch { + if ($_ -notmatch "published an invalid completion status") { + throw + } + } + Remove-Item -LiteralPath $status + } Set-Content -LiteralPath $ready -Value ( @{ Nonce = "nonce" @@ -366,7 +410,7 @@ if ($Action -eq "Server") { $_ | Out-File -LiteralPath $ServerOutputPath -Append $exitCode = 1 } finally { - Set-Content -LiteralPath $StatusPath -Value $exitCode + Publish-ServerStatus -Path $StatusPath -ExitCode $exitCode } exit $exitCode } @@ -533,9 +577,14 @@ try { Get-Content -LiteralPath $serverOutputPath | Out-File $outputPath -Append } if (Test-Path -LiteralPath $statusPath) { - $serverExitCode = [int](Get-Content -LiteralPath $statusPath -Raw) - if ($serverExitCode -ne 0 -and $exitCode -eq 0) { - $exitCode = $serverExitCode + try { + $serverExitCode = Read-ServerStatus -Path $statusPath + if ($serverExitCode -ne 0 -and $exitCode -eq 0) { + $exitCode = $serverExitCode + } + } catch { + $_ | Out-File $outputPath -Append + $exitCode = 1 } } elseif ($exitCode -eq 0) { "Timed out waiting for LocalSystem test server shutdown" | Out-File $outputPath -Append diff --git a/crates/agent-policy-tester/src/windows.rs b/crates/agent-policy-tester/src/windows.rs index 8dcb471ad..1c136183e 100644 --- a/crates/agent-policy-tester/src/windows.rs +++ b/crates/agent-policy-tester/src/windows.rs @@ -245,6 +245,7 @@ pub(crate) async fn run() -> anyhow::Result<()> { redirected_policy_paths_fail_closed(&agent_path).await?; management_write_tokens_survive_watcher_reload(&agent_path).await?; managed_policy_lifecycle(&agent_path).await?; + legacy_contract_and_interrupted_repair(&agent_path).await?; } } @@ -662,9 +663,30 @@ async fn validate_policy_by_pipe(pipe_name: &str, draft: &Value) -> anyhow::Resu ); let validation = validation_response.json()?["Validation"].clone(); ensure!(validation["IsValid"] == true, "policy validation failed"); + ensure!( + validation["ValidatorVersion"] == "now-package-broker-policy-validator/9", + "unexpected validator contract" + ); + ensure!( + validation["CanonicalDraft"].get("$schema").is_none() + && validation["CanonicalDraft"].get("PolicyVersion").is_none() + && validation["CanonicalDraft"]["PolicyFormatVersion"] == draft["PolicyFormatVersion"], + "canonical draft changed the format version or emitted legacy fields" + ); Ok(validation) } +async fn send_replacement(pipe_name: &str, replacement: &Value) -> anyhow::Result { + request_with_body( + pipe_name, + "PUT", + "/v1/policy", + Some("application/json"), + &serde_json::to_vec(replacement)?, + ) + .await +} + async fn replace_policy_response( agent: &AgentHarness, operation: &str, @@ -700,15 +722,7 @@ async fn replace_policy_response_by_pipe( "Draft": validation["CanonicalDraft"], "ValidationReceipt": validation["ValidationReceipt"] }); - let response = request_with_body( - pipe_name, - "PUT", - "/v1/policy", - Some("application/json"), - &serde_json::to_vec(&replacement_request)?, - ) - .await?; - Ok(response) + send_replacement(pipe_name, &replacement_request).await } async fn replace_policy( @@ -827,7 +841,9 @@ async fn standard_user_server( tokio::time::sleep(Duration::from_millis(50)).await; } - wait_for_log(&agent, "Policy management write denied").await + let result = wait_for_log(&agent, "Policy management write denied").await; + agent.stop().await?; + result } async fn standard_user_management(ready_path: &Path, nonce: &str, client: &ProcessIdentity) -> anyhow::Result<()> { @@ -872,12 +888,64 @@ async fn standard_user_management(ready_path: &Path, nonce: &str, client: &Proce "valid draft did not produce a canonical draft and receipt" ); - let mut invalid_draft = valid_draft.clone(); - invalid_draft["$schema"] = json!("https://example.com/not-the-policy-draft-schema.json"); + strict_contract_validation(pipe_name).await?; + + for operation in ["Create", "Update", "Repair", "ReplaceIdentity"] { + let denied = replace_policy_response_by_pipe( + pipe_name, + operation, + "Reject", + management["StoreToken"].clone(), + valid_draft.clone(), + ) + .await?; + ensure!( + denied.status == 403 && denied.json()?["Code"] == "AdministratorRequired", + "standard-user {operation} did not require an administrator" + ); + } + ensure!( + policy_management_by_pipe(pipe_name).await?["StoreToken"] == management["StoreToken"], + "denied writes changed the store token" + ); + Ok(()) +} + +async fn strict_contract_validation(pipe_name: &str) -> anyhow::Result<()> { + for version in ["1.0.0", "1.7.3"] { + let mut draft = policy_draft("tests.contract", "Contract"); + draft["PolicyFormatVersion"] = json!(version); + validate_policy_by_pipe(pipe_name, &draft).await?; + } + for (field, value, expected_path) in [ + ( + "$schema", + "https://devolutions.net/schemas/now-policy.schema.1.0.json", + "/$schema", + ), + ("PolicyVersion", "1.0.0", "/PolicyVersion"), + ("PolicyFormatVersion", "2.0.0", "/PolicyFormatVersion"), + ("PolicyFormatVersion", "broken", "/PolicyFormatVersion"), + ] { + let mut draft = policy_draft("tests.contract", "Contract"); + draft[field] = json!(value); + assert_invalid_draft(pipe_name, draft, expected_path).await?; + } + let mut legacy = policy_draft("tests.contract", "Contract"); + legacy + .as_object_mut() + .context("draft is not an object")? + .remove("PolicyFormatVersion"); + legacy["PolicyVersion"] = json!("1.0.0"); + legacy["$schema"] = json!("https://devolutions.net/schemas/now-policy.schema.1.0.json"); + assert_invalid_draft(pipe_name, legacy, "/PolicyVersion").await +} + +async fn assert_invalid_draft(pipe_name: &str, draft: Value, expected_path: &str) -> anyhow::Result<()> { let invalid_request = json!({ "RequestKind": "PolicyValidationRequest", "RequestVersion": "1.0", - "Draft": invalid_draft + "Draft": draft }); let invalid_response = request_with_body( pipe_name, @@ -895,32 +963,25 @@ async fn standard_user_management(ready_path: &Path, nonce: &str, client: &Proce let invalid_validation = invalid_response.json()?["Validation"].clone(); ensure!(invalid_validation["IsValid"] == false, "invalid draft was accepted"); ensure!( - invalid_validation.get("CanonicalDraft").is_none(), - "invalid draft returned a canonical draft" - ); - - let denied = replace_policy_response_by_pipe( - pipe_name, - "Create", - "Reject", - management["StoreToken"].clone(), - valid_draft, - ) - .await?; - ensure!( - denied.status == 403, - "standard-user Create returned HTTP {}", - denied.status + invalid_validation.get("CanonicalDraft").is_none() + && invalid_validation.get("ValidationReceipt").is_none() + && invalid_validation["ValidatorVersion"] == "now-package-broker-policy-validator/9", + "invalid draft returned a canonical draft, receipt, or wrong validator version" ); ensure!( - denied.json()?["Code"] == "AdministratorRequired", - "standard-user Create did not require an administrator" + invalid_validation["Findings"] + .as_array() + .is_some_and(|findings| findings + .iter() + .any(|finding| finding["Severity"] == "Error" && finding["Path"] == expected_path)), + "invalid draft did not report {expected_path}: {invalid_validation}" ); Ok(()) } async fn managed_policy_lifecycle(agent_path: &Path) -> anyhow::Result<()> { let mut agent = AgentHarness::start_managed_default(agent_path).await?; + strict_contract_validation(&agent.pipe_name).await?; let initial = policy_management(&agent).await?; ensure!( initial["State"] == "Missing", @@ -1051,6 +1112,7 @@ async fn managed_policy_lifecycle(agent_path: &Path) -> anyhow::Result<()> { "reused ConfirmOverwrite token did not conflict" ); + let confirmed = warnings_identity_and_receipts(&mut agent, agent_path, &confirmed).await?; agent.restart(agent_path).await?; let restarted = request(&agent.pipe_name, "GET", "/v1/policy").await?; ensure!( @@ -1089,6 +1151,150 @@ async fn managed_policy_lifecycle(agent_path: &Path) -> anyhow::Result<()> { Ok(()) } +async fn warnings_identity_and_receipts( + agent: &mut AgentHarness, + agent_path: &Path, + current: &Value, +) -> anyhow::Result { + let mut draft = policy_draft("tests.replaced-identity", "Compatible contract"); + draft["PolicyFormatVersion"] = json!("1.7.3"); + draft["Enforcement"]["AuditMode"] = json!(true); + let validation = validate_policy_by_pipe(&agent.pipe_name, &draft).await?; + ensure!( + validation["Findings"].as_array().is_some_and(|findings| findings + .iter() + .any(|finding| finding["Code"] == "AuditModeEnabled" && finding["Severity"] == "Warning")), + "audit-mode draft did not produce its warning" + ); + let mut replacement = json!({ + "RequestKind": "PolicyReplacementRequest", + "RequestVersion": "1.0", + "ExpectedStoreToken": current["Management"]["StoreToken"], + "Operation": "ReplaceIdentity", + "ConflictHandling": "Reject", + "WarningsAcknowledged": false, + "Draft": validation["CanonicalDraft"], + "ValidationReceipt": validation["ValidationReceipt"] + }); + let warning = send_replacement(&agent.pipe_name, &replacement).await?; + ensure!( + warning.status == 409 && warning.json()?["Code"] == "WarningConfirmationRequired", + "unacknowledged warnings were not rejected" + ); + replacement["WarningsAcknowledged"] = json!(true); + replacement["Draft"]["Metadata"]["Publisher"] = json!("Tampered after validation"); + let tampered = send_replacement(&agent.pipe_name, &replacement).await?; + ensure!( + tampered.status == 422 && tampered.json()?["Code"] == "ValidationFailed", + "receipt authorized a different draft" + ); + replacement["Draft"] = validation["CanonicalDraft"].clone(); + agent.restart(agent_path).await?; + let restarted = policy_management(agent).await?; + replacement["ExpectedStoreToken"] = restarted["StoreToken"].clone(); + let expired = send_replacement(&agent.pipe_name, &replacement).await?; + ensure!( + expired.status == 422 && expired.json()?["Code"] == "ValidationFailed", + "pre-restart receipt remained valid in a new validator instance" + ); + ensure!( + restarted["Policy"] == current["Policy"] + && policy_management(agent).await?["StoreToken"] == restarted["StoreToken"], + "rejected requests changed the policy or exact store token" + ); + let replaced = replace_policy(agent, "ReplaceIdentity", restarted["StoreToken"].clone(), draft).await?; + ensure!( + replaced["Policy"]["Metadata"]["Id"] == "tests.replaced-identity" + && replaced["Policy"]["Metadata"]["Revision"] == 1 + && replaced["Policy"]["PolicyFormatVersion"] == "1.7.3" + && replaced["Policy"].get("$schema").is_none() + && replaced["Policy"].get("PolicyVersion").is_none(), + "ReplaceIdentity did not preserve the compatible contract and reset revision" + ); + wait_for_log(agent, "Policy change succeeded").await?; + wait_for_log(agent, "replace_identity").await?; + wait_for_log(agent, "invalid_receipt").await?; + wait_for_log(agent, "warnings_not_acknowledged").await?; + Ok(replaced) +} + +async fn legacy_contract_and_interrupted_repair(agent_path: &Path) -> anyhow::Result<()> { + let mut legacy = empty_policy(); + legacy + .as_object_mut() + .context("policy is not an object")? + .remove("PolicyFormatVersion"); + legacy["$schema"] = json!("https://devolutions.net/schemas/now-policy.schema.1.0.json"); + legacy["PolicyVersion"] = json!("1.0.0"); + let mut mixed = legacy.clone(); + mixed["PolicyFormatVersion"] = json!("1.0.0"); + for original in [ + serde_json::to_vec(&legacy)?, + serde_json::to_vec(&mixed)?, + b"malformed-policy-secret-marker".to_vec(), + ] { + for marker_staging in [false, true] { + let data_dir = create_data_dir()?; + let policy_path = data_dir.path().join("policy.json"); + std::fs::write(&policy_path, &original)?; + secure_policy_path(&policy_path, false)?; + let prefix = ".policy.json.txn-11111111-2222-4333-8444-555555555555"; + let new_path = data_dir.path().join(format!("{prefix}.new")); + std::fs::write(&new_path, b"partial replacement")?; + secure_policy_path(&new_path, false)?; + let marker_path = data_dir.path().join(format!("{prefix}.marker.prepare")); + if marker_staging { + std::fs::write(&marker_path, br#"{"Version":"#)?; + secure_policy_path(&marker_path, false)?; + } + let mut agent = + AgentHarness::start_with_path(agent_path, data_dir, unique_pipe_name(), policy_path).await?; + let mut invalid = policy_management(&agent).await?; + ensure!( + invalid["State"] == "Invalid" && invalid["WriteCapability"] == "Writable", + "interrupted Repair did not retain a repairable invalid original: {invalid}" + ); + ensure!( + std::fs::read(&agent.policy_path)? == original && !new_path.exists() && !marker_path.exists(), + "recovery changed the original or retained prepublication remnants" + ); + ensure!( + request(&agent.pipe_name, "GET", "/v1/policy").await?.status == 404, + "legacy, mixed, or malformed policy became active" + ); + if original.starts_with(b"{") { + ensure!( + invalid["InvalidDiagnostics"]["Findings"] + .as_array() + .is_some_and(|findings| findings + .iter() + .any(|finding| finding["Code"] == "UnsupportedPolicyFormatVersion")), + "legacy contract did not produce its strict diagnostic" + ); + std::fs::write(&agent.policy_path, serde_json::to_vec(&empty_policy())?)?; + wait_for_management(&agent, |management| management["State"] == "Active").await?; + std::fs::write(&agent.policy_path, &original)?; + invalid = wait_for_management(&agent, |management| management["State"] == "Invalid").await?; + wait_for_log(&agent, "legacy_policy_contract").await?; + } + let repaired = replace_policy( + &agent, + "Repair", + invalid["StoreToken"].clone(), + policy_draft("tests.interrupted-repair", "Recovered"), + ) + .await?; + agent.restart(agent_path).await?; + ensure!( + policy_management(&agent).await?["Policy"] == repaired["Policy"], + "repaired policy did not survive restart" + ); + agent.stop().await?; + } + } + Ok(()) +} + async fn wait_for_management(agent: &AgentHarness, predicate: impl Fn(&Value) -> bool) -> anyhow::Result { let deadline = Instant::now() + Duration::from_secs(10); loop { @@ -1270,6 +1476,26 @@ async fn complete_snapshots_across_reload(agent_path: &Path) -> anyhow::Result<( mod tests { use super::*; + #[test] + fn policy_fixtures_use_the_current_contract() { + for policy in [full_policy(), empty_policy(), policy_draft("tests.contract", "Test")] { + assert_eq!(policy["PolicyFormatVersion"], "1.0.0"); + assert!(policy.get("$schema").is_none()); + assert!(policy.get("PolicyVersion").is_none()); + } + } + + #[test] + fn standard_user_token_rejects_wrong_identity_and_administrators() { + for (actual_sid, administrator, integrity) in [ + ("S-1-5-21-1-2-3-1002", false, SECURITY_MANDATORY_MEDIUM_RID), + ("S-1-5-21-1-2-3-1001", true, SECURITY_MANDATORY_MEDIUM_RID), + ("S-1-5-21-1-2-3-1001", false, 0x3000), + ] { + assert!(validate_standard_user_token(actual_sid, "S-1-5-21-1-2-3-1001", administrator, integrity).is_err()); + } + } + #[test] fn standard_user_token_requires_medium_integrity() { validate_standard_user_token( diff --git a/package/AgentWindowsManaged.Tests/InstalledAgentMigrationE2eTests.cs b/package/AgentWindowsManaged.Tests/InstalledAgentMigrationE2eTests.cs new file mode 100644 index 000000000..e76e4153d --- /dev/null +++ b/package/AgentWindowsManaged.Tests/InstalledAgentMigrationE2eTests.cs @@ -0,0 +1,438 @@ +using DevolutionsAgent.Actions; +using DevolutionsAgent.Resources; +using Microsoft.Deployment.WindowsInstaller; +using Microsoft.Win32.SafeHandles; +using Newtonsoft.Json.Linq; +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.IO.Pipes; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Principal; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace DevolutionsAgent.Installer.Tests; + +public sealed class InstalledAgentMigrationE2eTests +{ + private const string CurrentPolicy = + """{"PolicyFormatVersion":"1.7.3","PolicyType":"PackageBrokerPolicy","Metadata":{"Id":"installer-e2e","Publisher":"Test","Revision":17,"PublishedAt":"2026-01-01T00:00:00Z"},"Enforcement":{"DefaultDecision":"Deny","RulePrecedence":"PriorityThenDeny"},"Rules":[]}"""; + private readonly ITestOutputHelper output; + + public InstalledAgentMigrationE2eTests(ITestOutputHelper output) => this.output = output; + + [Fact] + public void AgentJobTerminatesChildWhenScopeThrows() + { + using Process child = Process.Start(new ProcessStartInfo( + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), + @"WindowsPowerShell\v1.0\powershell.exe"), + "-NoProfile -NonInteractive -Command Start-Sleep -Seconds 60") + { + UseShellExecute = false, + CreateNoWindow = true, + }); + try + { + InvalidOperationException failure = new("simulate a failed Agent assertion"); + void FailWithAssignedChild() + { + using AgentJob job = new(); + job.Assign(child); + throw failure; + } + Assert.Same(failure, Assert.Throws(FailWithAssignedChild)); + Assert.True(child.WaitForExit(10000), "Job disposal left the child running"); + } + finally + { + if (!child.HasExited) + { + child.Kill(); + Assert.True(child.WaitForExit(10000), "Cleanup did not stop the child"); + } + } + } + + [PackageBrokerInstallerTests.SystemFact] + public void ConvertedTransactionActivatesAndRetainsManagedAuthority() + { + Assert.True(WindowsIdentity.GetCurrent().IsSystem); + string agent = Environment.GetEnvironmentVariable("DEVOLUTIONS_AGENT_MIGRATION_TEST_EXE"); + Assert.False(string.IsNullOrWhiteSpace(agent), "The SYSTEM runner must supply the built Agent executable"); + Assert.True(File.Exists(agent), agent); + Assert.Equal(Includes.EXECUTABLE_NAME, Path.GetFileName(agent), ignoreCase: true); + using PackageBrokerPolicyActions.PinnedPath installedAgent = + PackageBrokerPolicyActions.PinPathWithoutReparse( + agent, leafIsDirectory: false, allowMissingLeaf: false, + leafAccess: WinAPI.GENERIC_READ | WinAPI.READ_CONTROL, verifyTrustedAncestors: true); + Assert.True( + PackageBrokerPolicyActions.TryVerifyLegacyPolicySourceSecurity( + File.GetAccessControl(agent), out string agentSecurityDiagnostic), + agentSecurityDiagnostic); + string root = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + $"dgw-installer-e2e-{Guid.NewGuid():N}"); + PackageBrokerPolicyActions.CreateDirectoryWithSecurity(root, Includes.PROGRAM_DATA_PACKAGE_BROKER_SDDL); + try + { + PackageBrokerPolicyActions.VerifyPackageBrokerSecurity(Directory.GetAccessControl(root)); + string vendor = Path.Combine(root, "Devolutions"); + string legacyDirectory = Path.Combine(vendor, "Agent"); + string managedDirectory = Path.Combine(vendor, "PackageBroker"); + foreach (string directory in new[] { vendor, legacyDirectory, managedDirectory }) + { + PackageBrokerPolicyActions.CreateDirectoryWithSecurity( + directory, Includes.PROGRAM_DATA_PACKAGE_BROKER_SDDL); + } + string source = Path.Combine(legacyDirectory, "package-broker-policy.json"); + string destination = Path.Combine(managedDirectory, "package-broker-policy.json"); + string marker = Path.Combine(managedDirectory, ".installer-e2e.migration"); + string authority = Path.Combine(managedDirectory, ".package-broker-managed-authority.v1"); + byte[] original = Encoding.UTF8.GetBytes(CurrentPolicy.Replace( + "\"PolicyFormatVersion\":", + "\"$schema\":\"https://devolutions.net/schemas/now-policy.schema.1.0.json\",\"PolicyVersion\":")); + File.WriteAllBytes(source, original); + FileSecurity security = new(); + security.SetSecurityDescriptorSddlForm(Includes.PROGRAM_DATA_PACKAGE_BROKER_FILE_SDDL); + File.SetAccessControl(source, security); + + int conversions = 0; + void Migrate() + { + Assert.Equal(ActionResult.Success, PackageBrokerPolicyActions.MigrateLegacyPolicy( + output.WriteLine, source, destination, marker, input => + { + Assert.False(File.Exists(destination)); + Assert.False(File.Exists(authority)); + Assert.Equal(original, input); + byte[] converted = PackageBrokerPolicyActions.ConvertWithInstalledAgent( + Path.GetDirectoryName(agent), input); + Assert.Equal(CurrentPolicy, Encoding.UTF8.GetString(converted)); + conversions++; + return converted; + })); + Assert.Equal(CurrentPolicy, File.ReadAllText(destination)); + Assert.True(File.Exists(authority)); + Assert.Empty(File.ReadAllBytes(authority)); + AssertPreserved(); + foreach (string path in new[] { destination, authority, marker, marker + ".original" }) + { + PackageBrokerPolicyActions.VerifyPackageBrokerSecurity(File.GetAccessControl(path)); + } + } + void AssertPreserved() + { + Assert.Equal(original, File.ReadAllBytes(source)); + Assert.Equal(original, File.ReadAllBytes(marker + ".original")); + Assert.True(File.Exists(marker)); + } + + Migrate(); + Assert.Equal(ActionResult.Success, PackageBrokerPolicyActions.RollbackLegacyPolicy( + output.WriteLine, source, destination, marker)); + Assert.False(File.Exists(destination)); + Assert.False(File.Exists(authority)); + AssertPreserved(); + Migrate(); + Assert.Equal(2, conversions); + PackageBrokerPolicyActions.CommitLegacyPolicy(output.WriteLine, source, destination, marker); + AssertPreserved(); + + using AgentProcess running = new(agent, root, output); + running.Start(); + JToken active = AssertActive(running, destination); + running.Stop(); + running.Start(); + Assert.True(JToken.DeepEquals(active, AssertActive(running, destination))); + AssertPreserved(); + running.Stop(); + File.Delete(destination); + running.Start(); + JObject management = running.Get("/v1/policy/management", 200)["Management"] as JObject; + Assert.NotNull(management); + Assert.Equal("DefaultPath", (string)management["Source"]); + Assert.Equal("Missing", (string)management["State"]); + Assert.Equal("active policy is unavailable", (string)running.Get("/v1/policy", 404)["Message"]); + Assert.True(File.Exists(authority)); + AssertPreserved(); + } + finally + { + for (int attempt = 0; ; attempt++) + { + try + { + Directory.Delete(root, recursive: true); + break; + } + catch (IOException) when (attempt < 19) + { + Thread.Sleep(250); + } + } + } + } + + private static JToken AssertActive(AgentProcess agent, string destination) + { + JObject response = agent.Get("/v1/policy", 200); + JToken policy = response["Policy"]; + Assert.NotNull(policy); + Assert.Equal("1.7.3", (string)policy["PolicyFormatVersion"]); + Assert.Equal("PackageBrokerPolicy", (string)policy["PolicyType"]); + Assert.Null(policy["PolicyVersion"]); + Assert.Null(policy["$schema"]); + Assert.Equal("installer-e2e", (string)policy["Metadata"]["Id"]); + Assert.Equal("Test", (string)policy["Metadata"]["Publisher"]); + Assert.Equal(17, (int)policy["Metadata"]["Revision"]); + Assert.Equal( + JObject.Parse(CurrentPolicy)["Metadata"]["PublishedAt"], + policy["Metadata"]["PublishedAt"]); + Assert.True(JToken.DeepEquals(JObject.Parse(CurrentPolicy)["Enforcement"], policy["Enforcement"])); + Assert.True(JToken.DeepEquals(new JArray(), policy["Rules"])); + Assert.Equal(CurrentPolicy, File.ReadAllText(destination)); + JObject management = agent.Get("/v1/policy/management", 200)["Management"] as JObject; + Assert.NotNull(management); + Assert.Equal("DefaultPath", (string)management["Source"]); + Assert.Equal("Active", (string)management["State"]); + return policy; + } + + private sealed class AgentProcess : IDisposable + { + private readonly string executable; + private readonly string root; + private readonly ITestOutputHelper output; + private readonly string pipeName = $"Devolutions.Now.PackageBroker.installer-e2e.{Guid.NewGuid():N}"; + private readonly AgentJob job; + private Process process; + private Task stdout; + private Task stderr; + + internal AgentProcess(string executable, string root, ITestOutputHelper output) + { + this.executable = executable; + this.root = root; + this.output = output; + JObject config = new() + { + ["LogFile"] = Path.Combine(root, "agent-installer-e2e"), + ["PackageBroker"] = new JObject + { + ["Enabled"] = true, + ["PipeName"] = @"\\.\pipe\" + pipeName, + }, + ["__debug__"] = new JObject { ["skip_broker_signature_validation"] = true }, + }; + File.WriteAllText(Path.Combine(root, "agent.json"), config.ToString()); + job = new AgentJob(); + } + + internal void Start() + { + Assert.Null(process); + stdout = null; + stderr = null; + ProcessStartInfo start = new(executable, "run") + { + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = root, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + start.EnvironmentVariables["DAGENT_CONFIG_PATH"] = root; + start.EnvironmentVariables["ProgramData"] = root; + process = Process.Start(start); + job.Assign(process); + stdout = process.StandardOutput.ReadToEndAsync(); + stderr = process.StandardError.ReadToEndAsync(); + Stopwatch timer = Stopwatch.StartNew(); + while (true) + { + Assert.False(process.HasExited, "Agent exited before its broker became ready"); + try + { + Get("/v1/health", 200); + return; + } + catch (TimeoutException) when (timer.Elapsed < TimeSpan.FromSeconds(20)) + { + Thread.Sleep(50); + } + catch (IOException) when (timer.Elapsed < TimeSpan.FromSeconds(20)) + { + Thread.Sleep(50); + } + } + } + + internal JObject Get(string path, int expectedStatus) + { + using NamedPipeClientStream pipe = new( + ".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous); + pipe.Connect(1000); + Task request = Exchange(pipe, path); + if (Task.WhenAny(request, Task.Delay(TimeSpan.FromSeconds(10))).GetAwaiter().GetResult() != request) + { + throw new TimeoutException($"timed out reading {path}"); + } + string response = request.GetAwaiter().GetResult(); + int headerEnd = response.IndexOf("\r\n\r\n", StringComparison.Ordinal); + Assert.True(headerEnd > 0, response); + Assert.Equal(expectedStatus.ToString(), response.Split(' ')[1]); + return JObject.Parse(response.Substring(headerEnd + 4)); + } + + private static async Task Exchange(NamedPipeClientStream pipe, string path) + { + byte[] request = Encoding.ASCII.GetBytes( + $"GET {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"); + await pipe.WriteAsync(request, 0, request.Length).ConfigureAwait(false); + await pipe.FlushAsync().ConfigureAwait(false); + using MemoryStream response = new(); + await pipe.CopyToAsync(response).ConfigureAwait(false); + return Encoding.UTF8.GetString(response.ToArray()); + } + + internal void Stop() + { + if (process == null) + { + return; + } + try + { + if (!process.HasExited) + { + try + { + process.Kill(); + } + catch (InvalidOperationException) when (process.HasExited) + { + } + catch (Win32Exception) when (process.WaitForExit(10000)) + { + } + } + Assert.True(process.WaitForExit(10000), "Agent did not stop"); + if (stdout != null) + { + output.WriteLine(stdout.GetAwaiter().GetResult()); + output.WriteLine(stderr.GetAwaiter().GetResult()); + } + foreach (string log in Directory.GetFiles(root, "agent-installer-e2e*")) + { + output.WriteLine(File.ReadAllText(log)); + } + } + finally + { + if (process.HasExited) + { + process.Dispose(); + process = null; + } + } + } + + public void Dispose() + { + job.Dispose(); + Stop(); + } + } + + private sealed class AgentJob : IDisposable + { + private const uint JobObjectLimitKillOnJobClose = 0x2000; + private const int JobObjectExtendedLimitInformation = 9; + private readonly SafeFileHandle handle; + + internal AgentJob() + { + handle = CreateJobObjectW(IntPtr.Zero, null); + if (handle.IsInvalid) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + ExtendedLimitInformation limits = new() + { + BasicLimitInformation = new BasicLimitInformation { LimitFlags = JobObjectLimitKillOnJobClose }, + }; + if (!SetInformationJobObject( + handle, JobObjectExtendedLimitInformation, ref limits, Marshal.SizeOf())) + { + int error = Marshal.GetLastWin32Error(); + handle.Dispose(); + throw new Win32Exception(error); + } + } + + internal void Assign(Process process) + { + if (!AssignProcessToJobObject(handle, process.Handle)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + } + + public void Dispose() => handle.Dispose(); + + [StructLayout(LayoutKind.Sequential)] + private struct BasicLimitInformation + { + internal long PerProcessUserTimeLimit; + internal long PerJobUserTimeLimit; + internal uint LimitFlags; + internal UIntPtr MinimumWorkingSetSize; + internal UIntPtr MaximumWorkingSetSize; + internal uint ActiveProcessLimit; + internal UIntPtr Affinity; + internal uint PriorityClass; + internal uint SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + private struct IoCounters + { + internal ulong ReadOperationCount; + internal ulong WriteOperationCount; + internal ulong OtherOperationCount; + internal ulong ReadTransferCount; + internal ulong WriteTransferCount; + internal ulong OtherTransferCount; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ExtendedLimitInformation + { + internal BasicLimitInformation BasicLimitInformation; + internal IoCounters IoInfo; + internal UIntPtr ProcessMemoryLimit; + internal UIntPtr JobMemoryLimit; + internal UIntPtr PeakProcessMemoryUsed; + internal UIntPtr PeakJobMemoryUsed; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateJobObjectW(IntPtr attributes, string name); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetInformationJobObject( + SafeFileHandle job, int informationClass, ref ExtendedLimitInformation information, int length); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool AssignProcessToJobObject(SafeFileHandle job, IntPtr process); + } +} From 59f3ccea82fdf0036802932c67691196d232f939 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 16 Sep 2026 02:56:10 +0900 Subject: [PATCH 08/39] fix(agent): restore NuGet test imports for SYSTEM Pass the restored package root explicitly to the LocalSystem test invocation so generated imports discover the test SDK and produce its required privileged-test result. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 10 +++++++++- crates/agent-policy-tester/run-as-system.ps1 | 11 +++++++++-- .../agent-policy-tester/run-installer-tests.ps1 | 15 ++++++++++++++- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1fe34617e..134a0d2d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1452,7 +1452,15 @@ jobs: run: | $scriptPath = Resolve-Path -Path "./crates/agent-policy-tester/run-as-system.ps1" $dotnetPath = (Get-Command dotnet.exe).Source - psexec -accepteula -s pwsh.exe -NoProfile -File $scriptPath -DotnetPath $dotnetPath + $nuGetPackagesPath = ( + & $dotnetPath nuget locals global-packages --list | + Select-String '^global-packages:\s*(.+)$' + ).Matches[0].Groups[1].Value.Trim() + if ([string]::IsNullOrWhiteSpace($nuGetPackagesPath) -or -not (Test-Path -LiteralPath $nuGetPackagesPath)) { + throw "Could not resolve the restored NuGet package root for LocalSystem" + } + psexec -accepteula -s pwsh.exe -NoProfile -File $scriptPath ` + -DotnetPath $dotnetPath -NuGetPackagesPath $nuGetPackagesPath $exitCode = $LASTEXITCODE Get-Content -Path ./crates/agent-policy-tester/agent-policy-tester.out if ($exitCode -ne 0) { diff --git a/crates/agent-policy-tester/run-as-system.ps1 b/crates/agent-policy-tester/run-as-system.ps1 index 2e23d612f..ceeb8f71c 100644 --- a/crates/agent-policy-tester/run-as-system.ps1 +++ b/crates/agent-policy-tester/run-as-system.ps1 @@ -1,5 +1,6 @@ param( - [string] $DotnetPath = (Join-Path $env:ProgramFiles "dotnet\dotnet.exe") + [string] $DotnetPath = (Join-Path $env:ProgramFiles "dotnet\dotnet.exe"), + [Parameter(Mandatory)] [string] $NuGetPackagesPath ) $ErrorActionPreference = "Stop" @@ -15,6 +16,7 @@ $installerProject = Join-Path $workspacePath "package\AgentWindowsManaged.Tests\ $installerOutput = Join-Path $workspacePath "package\AgentWindowsManaged.Tests\bin\Debug\net48" $stagedInstallerOutput = Join-Path $stagingPath "installer-tests" $resultsPath = Join-Path $PSScriptRoot "installer-test-results" +$stagedResultsPath = Join-Path $stagingPath "installer-test-results" $exitCode = 1 $previousTemp = $env:TEMP $previousTmp = $env:TMP @@ -27,6 +29,7 @@ try { if ([System.IO.DriveInfo]::new([System.IO.Path]::GetPathRoot($workspacePath)).DriveType -ne 'Fixed') { throw "Use a local fixed-volume workspace path visible to LocalSystem, not a mapped drive" } + $NuGetPackagesPath = (Resolve-Path -LiteralPath $NuGetPackagesPath).Path Add-Type -TypeDefinition @' using System; using System.ComponentModel; @@ -79,6 +82,9 @@ public static class AgentPolicyTesterNativeDirectory if (Get-ChildItem -LiteralPath $stagingPath -Force) { throw "The atomically protected staged tester directory was not empty" } + if (Test-Path -LiteralPath $resultsPath) { + Remove-Item -LiteralPath $resultsPath -Recurse -Force + } $env:TEMP = Join-Path $stagingPath "scratch" $env:TMP = $env:TEMP New-Item -ItemType Directory -Path $env:TEMP | Out-Null @@ -103,7 +109,8 @@ public static class AgentPolicyTesterNativeDirectory $exitCode = $LASTEXITCODE & (Join-Path $PSScriptRoot "run-installer-tests.ps1") ` -ProjectPath $installerProject -TestOutputPath $stagedInstallerOutput ` - -AgentPath $stagedAgentPath -ResultsPath $resultsPath -DotnetPath $DotnetPath ` + -AgentPath $stagedAgentPath -ResultsPath $stagedResultsPath -ArtifactResultsPath $resultsPath ` + -DotnetPath $DotnetPath -NuGetPackagesPath $NuGetPackagesPath ` 2>&1 | Out-File $outputPath -Append if ($LASTEXITCODE -ne 0) { $exitCode = $LASTEXITCODE diff --git a/crates/agent-policy-tester/run-installer-tests.ps1 b/crates/agent-policy-tester/run-installer-tests.ps1 index e2c67c402..1e96f8090 100644 --- a/crates/agent-policy-tester/run-installer-tests.ps1 +++ b/crates/agent-policy-tester/run-installer-tests.ps1 @@ -3,17 +3,26 @@ param( [Parameter(Mandatory)] [string] $TestOutputPath, [Parameter(Mandatory)] [string] $AgentPath, [Parameter(Mandatory)] [string] $ResultsPath, - [Parameter(Mandatory)] [string] $DotnetPath + [Parameter(Mandatory)] [string] $ArtifactResultsPath, + [Parameter(Mandatory)] [string] $DotnetPath, + [Parameter(Mandatory)] [string] $NuGetPackagesPath ) $ErrorActionPreference = "Stop" $exitCode = 1 $previousAgent = $env:DEVOLUTIONS_AGENT_MIGRATION_TEST_EXE +$previousNuGetPackages = $env:NUGET_PACKAGES try { if (-not [System.Security.Principal.WindowsIdentity]::GetCurrent().IsSystem) { throw "Installer transaction tests must execute as LocalSystem" } + $nuGetPackageRoot = (Resolve-Path -LiteralPath $NuGetPackagesPath).Path + $separator = [System.IO.Path]::DirectorySeparatorChar.ToString() + if (-not $nuGetPackageRoot.EndsWith($separator, [System.StringComparison]::Ordinal)) { + $nuGetPackageRoot += $separator + } + $env:NUGET_PACKAGES = $nuGetPackageRoot $testClass = 'DevolutionsAgent.Installer.Tests.PackageBrokerInstallerTests' $expectedTests = @( "$testClass.TransactionTestsRunAsLocalSystem" @@ -39,6 +48,7 @@ try { $env:DEVOLUTIONS_AGENT_MIGRATION_TEST_EXE = (Resolve-Path -LiteralPath $AgentPath).Path & $DotnetPath test $ProjectPath --no-build --no-restore --configuration Debug --framework net48 ` "-p:OutputPath=$TestOutputPath\" -p:AppendTargetFrameworkToOutputPath=false ` + "-p:NuGetPackageRoot=$env:NUGET_PACKAGES" ` --filter $filter --logger "trx;LogFileName=installer-system.trx" --results-directory $ResultsPath $exitCode = $LASTEXITCODE if ($exitCode -ne 0) { @@ -61,6 +71,8 @@ try { throw "Required SYSTEM installer test did not pass exactly once: $name" } } + New-Item -ItemType Directory -Path $ArtifactResultsPath -Force | Out-Null + Copy-Item -LiteralPath $trxPath -Destination $ArtifactResultsPath -Force Write-Output "Verified all 12 installer SystemFacts and the installed-Agent migration E2E: 13 passed, zero skipped" } catch { Write-Output $_ @@ -69,6 +81,7 @@ try { } } finally { $env:DEVOLUTIONS_AGENT_MIGRATION_TEST_EXE = $previousAgent + $env:NUGET_PACKAGES = $previousNuGetPackages } exit $exitCode From f331baeb89db567071f5c9a6c3ff206f0e945c17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 16 Sep 2026 03:08:51 +0900 Subject: [PATCH 09/39] fix(agent): run installer tests as LocalSystem Preserve the restored NuGet imports and the product Agent filename when running the privileged installer lifecycle tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/agent-policy-tester/run-as-system.ps1 | 2 +- .../InstalledAgentMigrationE2eTests.cs | 14 +++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/agent-policy-tester/run-as-system.ps1 b/crates/agent-policy-tester/run-as-system.ps1 index ceeb8f71c..be1938f88 100644 --- a/crates/agent-policy-tester/run-as-system.ps1 +++ b/crates/agent-policy-tester/run-as-system.ps1 @@ -11,7 +11,7 @@ $agentPath = Join-Path $workspacePath "target/debug/devolutions-agent.exe" $outputPath = Join-Path $PSScriptRoot "agent-policy-tester.out" $stagingPath = Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) "dgw-agent-policy-tester-$([guid]::NewGuid().ToString('N'))" $stagedTesterPath = Join-Path $stagingPath "agent-policy-tester.exe" -$stagedAgentPath = Join-Path $stagingPath "devolutions-agent.exe" +$stagedAgentPath = Join-Path $stagingPath "DevolutionsAgent.exe" $installerProject = Join-Path $workspacePath "package\AgentWindowsManaged.Tests\DevolutionsAgent.Installer.Tests.csproj" $installerOutput = Join-Path $workspacePath "package\AgentWindowsManaged.Tests\bin\Debug\net48" $stagedInstallerOutput = Join-Path $stagingPath "installer-tests" diff --git a/package/AgentWindowsManaged.Tests/InstalledAgentMigrationE2eTests.cs b/package/AgentWindowsManaged.Tests/InstalledAgentMigrationE2eTests.cs index e76e4153d..1d6c0b2a8 100644 --- a/package/AgentWindowsManaged.Tests/InstalledAgentMigrationE2eTests.cs +++ b/package/AgentWindowsManaged.Tests/InstalledAgentMigrationE2eTests.cs @@ -27,6 +27,14 @@ public sealed class InstalledAgentMigrationE2eTests public InstalledAgentMigrationE2eTests(ITestOutputHelper output) => this.output = output; + [Theory] + [InlineData("DevolutionsAgent.exe")] + [InlineData("devolutionsagent.exe")] + public void AgentExecutableNameComparisonIsCaseInsensitive(string fileName) + { + Assert.True(IsExpectedAgentExecutableName(fileName)); + } + [Fact] public void AgentJobTerminatesChildWhenScopeThrows() { @@ -67,7 +75,7 @@ public void ConvertedTransactionActivatesAndRetainsManagedAuthority() string agent = Environment.GetEnvironmentVariable("DEVOLUTIONS_AGENT_MIGRATION_TEST_EXE"); Assert.False(string.IsNullOrWhiteSpace(agent), "The SYSTEM runner must supply the built Agent executable"); Assert.True(File.Exists(agent), agent); - Assert.Equal(Includes.EXECUTABLE_NAME, Path.GetFileName(agent), ignoreCase: true); + Assert.True(IsExpectedAgentExecutableName(Path.GetFileName(agent)), agent); using PackageBrokerPolicyActions.PinnedPath installedAgent = PackageBrokerPolicyActions.PinPathWithoutReparse( agent, leafIsDirectory: false, allowMissingLeaf: false, @@ -91,6 +99,7 @@ public void ConvertedTransactionActivatesAndRetainsManagedAuthority() PackageBrokerPolicyActions.CreateDirectoryWithSecurity( directory, Includes.PROGRAM_DATA_PACKAGE_BROKER_SDDL); } + string source = Path.Combine(legacyDirectory, "package-broker-policy.json"); string destination = Path.Combine(managedDirectory, "package-broker-policy.json"); string marker = Path.Combine(managedDirectory, ".installer-e2e.migration"); @@ -205,6 +214,9 @@ private static JToken AssertActive(AgentProcess agent, string destination) return policy; } + private static bool IsExpectedAgentExecutableName(string fileName) => + string.Equals(Includes.EXECUTABLE_NAME, fileName, StringComparison.OrdinalIgnoreCase); + private sealed class AgentProcess : IDisposable { private readonly string executable; From 5f8d6d8383a6af4236d4051753b412ed389cb3e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 16 Sep 2026 03:23:43 +0900 Subject: [PATCH 10/39] fix(agent): authenticate installer E2E client Run migration lifecycle probes through the staged trusted test client instead of the hosted .NET test process, whose executable image cannot be retained by the package broker. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/agent-policy-tester/run-as-system.ps1 | 3 ++ crates/agent-policy-tester/src/windows.rs | 26 +++++++++- .../InstalledAgentMigrationE2eTests.cs | 49 ++++++++++--------- 3 files changed, 54 insertions(+), 24 deletions(-) diff --git a/crates/agent-policy-tester/run-as-system.ps1 b/crates/agent-policy-tester/run-as-system.ps1 index be1938f88..00d872422 100644 --- a/crates/agent-policy-tester/run-as-system.ps1 +++ b/crates/agent-policy-tester/run-as-system.ps1 @@ -20,6 +20,7 @@ $stagedResultsPath = Join-Path $stagingPath "installer-test-results" $exitCode = 1 $previousTemp = $env:TEMP $previousTmp = $env:TMP +$previousInstallerTester = $env:AGENT_POLICY_TESTER_E2E_EXE try { Set-Content -LiteralPath $outputPath -Value "" @@ -107,6 +108,7 @@ public static class AgentPolicyTesterNativeDirectory Get-Acl -LiteralPath $stagedTesterPath | Format-List Owner, Sddl | Out-File $outputPath -Append & $stagedTesterPath $stagedAgentPath elevated 2>&1 | Out-File $outputPath -Append $exitCode = $LASTEXITCODE + $env:AGENT_POLICY_TESTER_E2E_EXE = $stagedTesterPath & (Join-Path $PSScriptRoot "run-installer-tests.ps1") ` -ProjectPath $installerProject -TestOutputPath $stagedInstallerOutput ` -AgentPath $stagedAgentPath -ResultsPath $stagedResultsPath -ArtifactResultsPath $resultsPath ` @@ -121,6 +123,7 @@ public static class AgentPolicyTesterNativeDirectory } finally { $env:TEMP = $previousTemp $env:TMP = $previousTmp + $env:AGENT_POLICY_TESTER_E2E_EXE = $previousInstallerTester for ($attempt = 0; $attempt -lt 20 -and (Test-Path -LiteralPath $stagingPath); $attempt++) { try { Remove-Item -LiteralPath $stagingPath -Recurse -Force diff --git a/crates/agent-policy-tester/src/windows.rs b/crates/agent-policy-tester/src/windows.rs index 1c136183e..c4bae6c78 100644 --- a/crates/agent-policy-tester/src/windows.rs +++ b/crates/agent-policy-tester/src/windows.rs @@ -194,6 +194,7 @@ enum Mode { StandardServer, StandardClient, Elevated, + Probe, } impl Mode { @@ -202,7 +203,8 @@ impl Mode { "standard-server" => Ok(Self::StandardServer), "standard-client" => Ok(Self::StandardClient), "elevated" => Ok(Self::Elevated), - _ => bail!("unknown mode '{value}'; expected 'standard-server', 'standard-client', or 'elevated'"), + "probe" => Ok(Self::Probe), + _ => bail!("unknown mode '{value}'; expected 'standard-server', 'standard-client', 'elevated', or 'probe'"), } } } @@ -247,6 +249,23 @@ pub(crate) async fn run() -> anyhow::Result<()> { managed_policy_lifecycle(&agent_path).await?; legacy_contract_and_interrupted_repair(&agent_path).await?; } + Mode::Probe => { + verify_local_system()?; + let pipe_name = next_string(&mut args, "pipe name")?; + let path = next_string(&mut args, "request path")?; + ensure!(args.next().is_none(), "unexpected probe arguments"); + probe(&pipe_name, &path).await?; + } + } + + async fn probe(pipe_name: &str, path: &str) -> anyhow::Result<()> { + let response = request(pipe_name, "GET", path).await?; + let body = response.json()?; + let mut stdout = std::io::stdout().lock(); + serde_json::to_writer(&mut stdout, &json!({ "Status": response.status, "Body": body }))?; + stdout.write_all(b"\n")?; + stdout.flush()?; + Ok(()) } Ok(()) @@ -1496,6 +1515,11 @@ mod tests { } } + #[test] + fn probe_mode_is_only_available_to_a_local_system_client() { + assert!(matches!(Mode::parse("probe"), Ok(Mode::Probe))); + } + #[test] fn standard_user_token_requires_medium_integrity() { validate_standard_user_token( diff --git a/package/AgentWindowsManaged.Tests/InstalledAgentMigrationE2eTests.cs b/package/AgentWindowsManaged.Tests/InstalledAgentMigrationE2eTests.cs index 1d6c0b2a8..0db6e859d 100644 --- a/package/AgentWindowsManaged.Tests/InstalledAgentMigrationE2eTests.cs +++ b/package/AgentWindowsManaged.Tests/InstalledAgentMigrationE2eTests.cs @@ -7,7 +7,6 @@ using System.ComponentModel; using System.Diagnostics; using System.IO; -using System.IO.Pipes; using System.Runtime.InteropServices; using System.Security.AccessControl; using System.Security.Principal; @@ -220,6 +219,7 @@ private static bool IsExpectedAgentExecutableName(string fileName) => private sealed class AgentProcess : IDisposable { private readonly string executable; + private readonly string tester; private readonly string root; private readonly ITestOutputHelper output; private readonly string pipeName = $"Devolutions.Now.PackageBroker.installer-e2e.{Guid.NewGuid():N}"; @@ -231,6 +231,9 @@ private sealed class AgentProcess : IDisposable internal AgentProcess(string executable, string root, ITestOutputHelper output) { this.executable = executable; + tester = Environment.GetEnvironmentVariable("AGENT_POLICY_TESTER_E2E_EXE"); + Assert.False(string.IsNullOrWhiteSpace(tester), "The SYSTEM runner must supply the policy tester executable"); + Assert.True(File.Exists(tester), tester); this.root = root; this.output = output; JObject config = new() @@ -288,30 +291,30 @@ internal void Start() internal JObject Get(string path, int expectedStatus) { - using NamedPipeClientStream pipe = new( - ".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous); - pipe.Connect(1000); - Task request = Exchange(pipe, path); - if (Task.WhenAny(request, Task.Delay(TimeSpan.FromSeconds(10))).GetAwaiter().GetResult() != request) + ProcessStartInfo start = new(tester, $"\"{tester}\" probe \"\\\\.\\pipe\\{pipeName}\" \"{path}\"") { - throw new TimeoutException($"timed out reading {path}"); + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + using Process client = Process.Start(start); + Task stdout = client.StandardOutput.ReadToEndAsync(); + Task stderr = client.StandardError.ReadToEndAsync(); + if (!client.WaitForExit(10000)) + { + client.Kill(); + throw new TimeoutException($"timed out probing {path}"); } - string response = request.GetAwaiter().GetResult(); - int headerEnd = response.IndexOf("\r\n\r\n", StringComparison.Ordinal); - Assert.True(headerEnd > 0, response); - Assert.Equal(expectedStatus.ToString(), response.Split(' ')[1]); - return JObject.Parse(response.Substring(headerEnd + 4)); - } - - private static async Task Exchange(NamedPipeClientStream pipe, string path) - { - byte[] request = Encoding.ASCII.GetBytes( - $"GET {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"); - await pipe.WriteAsync(request, 0, request.Length).ConfigureAwait(false); - await pipe.FlushAsync().ConfigureAwait(false); - using MemoryStream response = new(); - await pipe.CopyToAsync(response).ConfigureAwait(false); - return Encoding.UTF8.GetString(response.ToArray()); + string standardOutput = stdout.GetAwaiter().GetResult(); + string standardError = stderr.GetAwaiter().GetResult(); + if (client.ExitCode != 0) + { + throw new IOException($"policy tester probe failed for {path}: {standardError}"); + } + JObject response = JObject.Parse(standardOutput); + Assert.Equal(expectedStatus, (int)response["Status"]); + return response["Body"] as JObject; } internal void Stop() From 632427cf46e5c784a735846564ad9106b6018fc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Thu, 17 Sep 2026 09:39:08 +0900 Subject: [PATCH 11/39] test(agent): retain canonical policy E2E Remove installer migration and retired-policy compatibility coverage while preserving the canonical policy management lifecycle and its security boundaries. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 27 +- crates/agent-policy-tester/run-as-system.ps1 | 42 +- .../run-installer-tests.ps1 | 87 ---- crates/agent-policy-tester/src/windows.rs | 211 ++------ .../InstalledAgentMigrationE2eTests.cs | 453 ------------------ 5 files changed, 57 insertions(+), 763 deletions(-) delete mode 100644 crates/agent-policy-tester/run-installer-tests.ps1 delete mode 100644 package/AgentWindowsManaged.Tests/InstalledAgentMigrationE2eTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 134a0d2d0..2d8526760 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1431,11 +1431,6 @@ jobs: exit $LASTEXITCODE } - - name: Build installer tests before entering LocalSystem - id: build-installer-tests - shell: pwsh - run: dotnet build package/AgentWindowsManaged.Tests/DevolutionsAgent.Installer.Tests.csproj --configuration Debug --framework net48 - - name: Run Agent policy tester as standard user shell: pwsh run: | @@ -1447,35 +1442,17 @@ jobs: } - name: Run Agent policy tester as LocalSystem - if: ${{ !cancelled() && steps.build-policy-executables.outcome == 'success' && steps.build-installer-tests.outcome == 'success' }} + if: ${{ !cancelled() && steps.build-policy-executables.outcome == 'success' }} shell: pwsh run: | $scriptPath = Resolve-Path -Path "./crates/agent-policy-tester/run-as-system.ps1" - $dotnetPath = (Get-Command dotnet.exe).Source - $nuGetPackagesPath = ( - & $dotnetPath nuget locals global-packages --list | - Select-String '^global-packages:\s*(.+)$' - ).Matches[0].Groups[1].Value.Trim() - if ([string]::IsNullOrWhiteSpace($nuGetPackagesPath) -or -not (Test-Path -LiteralPath $nuGetPackagesPath)) { - throw "Could not resolve the restored NuGet package root for LocalSystem" - } - psexec -accepteula -s pwsh.exe -NoProfile -File $scriptPath ` - -DotnetPath $dotnetPath -NuGetPackagesPath $nuGetPackagesPath + psexec -accepteula -s pwsh.exe -NoProfile -File $scriptPath $exitCode = $LASTEXITCODE Get-Content -Path ./crates/agent-policy-tester/agent-policy-tester.out if ($exitCode -ne 0) { exit $exitCode } - - name: Upload SYSTEM installer test results - if: ${{ always() }} - uses: actions/upload-artifact@v7 - with: - name: agent-policy-installer-system-results - path: | - crates/agent-policy-tester/installer-test-results/*.trx - crates/agent-policy-tester/agent-policy-tester.out - - name: Run policy route authorization tests shell: pwsh run: cargo test --locked -p now-package-broker --features dev-skip-broker-signature diff --git a/crates/agent-policy-tester/run-as-system.ps1 b/crates/agent-policy-tester/run-as-system.ps1 index 00d872422..f26337f00 100644 --- a/crates/agent-policy-tester/run-as-system.ps1 +++ b/crates/agent-policy-tester/run-as-system.ps1 @@ -1,8 +1,3 @@ -param( - [string] $DotnetPath = (Join-Path $env:ProgramFiles "dotnet\dotnet.exe"), - [Parameter(Mandatory)] [string] $NuGetPackagesPath -) - $ErrorActionPreference = "Stop" $workspacePath = (Resolve-Path (Join-Path $PSScriptRoot "../..")).Path @@ -11,16 +6,7 @@ $agentPath = Join-Path $workspacePath "target/debug/devolutions-agent.exe" $outputPath = Join-Path $PSScriptRoot "agent-policy-tester.out" $stagingPath = Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) "dgw-agent-policy-tester-$([guid]::NewGuid().ToString('N'))" $stagedTesterPath = Join-Path $stagingPath "agent-policy-tester.exe" -$stagedAgentPath = Join-Path $stagingPath "DevolutionsAgent.exe" -$installerProject = Join-Path $workspacePath "package\AgentWindowsManaged.Tests\DevolutionsAgent.Installer.Tests.csproj" -$installerOutput = Join-Path $workspacePath "package\AgentWindowsManaged.Tests\bin\Debug\net48" -$stagedInstallerOutput = Join-Path $stagingPath "installer-tests" -$resultsPath = Join-Path $PSScriptRoot "installer-test-results" -$stagedResultsPath = Join-Path $stagingPath "installer-test-results" $exitCode = 1 -$previousTemp = $env:TEMP -$previousTmp = $env:TMP -$previousInstallerTester = $env:AGENT_POLICY_TESTER_E2E_EXE try { Set-Content -LiteralPath $outputPath -Value "" @@ -30,7 +16,6 @@ try { if ([System.IO.DriveInfo]::new([System.IO.Path]::GetPathRoot($workspacePath)).DriveType -ne 'Fixed') { throw "Use a local fixed-volume workspace path visible to LocalSystem, not a mapped drive" } - $NuGetPackagesPath = (Resolve-Path -LiteralPath $NuGetPackagesPath).Path Add-Type -TypeDefinition @' using System; using System.ComponentModel; @@ -83,19 +68,10 @@ public static class AgentPolicyTesterNativeDirectory if (Get-ChildItem -LiteralPath $stagingPath -Force) { throw "The atomically protected staged tester directory was not empty" } - if (Test-Path -LiteralPath $resultsPath) { - Remove-Item -LiteralPath $resultsPath -Recurse -Force - } - $env:TEMP = Join-Path $stagingPath "scratch" - $env:TMP = $env:TEMP - New-Item -ItemType Directory -Path $env:TEMP | Out-Null - Copy-Item -LiteralPath $testerPath -Destination $stagedTesterPath - Copy-Item -LiteralPath $agentPath -Destination $stagedAgentPath - Copy-Item -LiteralPath $installerOutput -Destination $stagedInstallerOutput -Recurse - & icacls.exe $stagingPath /setowner '*S-1-5-18' /T /Q 2>&1 | Out-File $outputPath -Append + & icacls.exe $stagedTesterPath /setowner '*S-1-5-18' 2>&1 | Out-File $outputPath -Append if ($LASTEXITCODE -ne 0) { - throw "Failed to set the staged executable and installer test owners" + throw "Failed to set the staged tester owner" } & icacls.exe $stagedTesterPath /inheritance:r /grant:r '*S-1-5-18:(F)' '*S-1-5-32-544:(F)' 2>&1 | Out-File $outputPath -Append @@ -106,24 +82,12 @@ public static class AgentPolicyTesterNativeDirectory "Staged policy tester at $stagedTesterPath" | Out-File $outputPath -Append Get-Acl -LiteralPath $stagingPath | Format-List Owner, Sddl | Out-File $outputPath -Append Get-Acl -LiteralPath $stagedTesterPath | Format-List Owner, Sddl | Out-File $outputPath -Append - & $stagedTesterPath $stagedAgentPath elevated 2>&1 | Out-File $outputPath -Append + & $stagedTesterPath $agentPath elevated 2>&1 | Out-File $outputPath -Append $exitCode = $LASTEXITCODE - $env:AGENT_POLICY_TESTER_E2E_EXE = $stagedTesterPath - & (Join-Path $PSScriptRoot "run-installer-tests.ps1") ` - -ProjectPath $installerProject -TestOutputPath $stagedInstallerOutput ` - -AgentPath $stagedAgentPath -ResultsPath $stagedResultsPath -ArtifactResultsPath $resultsPath ` - -DotnetPath $DotnetPath -NuGetPackagesPath $NuGetPackagesPath ` - 2>&1 | Out-File $outputPath -Append - if ($LASTEXITCODE -ne 0) { - $exitCode = $LASTEXITCODE - } } catch { $_ | Out-File $outputPath -Append $exitCode = 1 } finally { - $env:TEMP = $previousTemp - $env:TMP = $previousTmp - $env:AGENT_POLICY_TESTER_E2E_EXE = $previousInstallerTester for ($attempt = 0; $attempt -lt 20 -and (Test-Path -LiteralPath $stagingPath); $attempt++) { try { Remove-Item -LiteralPath $stagingPath -Recurse -Force diff --git a/crates/agent-policy-tester/run-installer-tests.ps1 b/crates/agent-policy-tester/run-installer-tests.ps1 deleted file mode 100644 index 1e96f8090..000000000 --- a/crates/agent-policy-tester/run-installer-tests.ps1 +++ /dev/null @@ -1,87 +0,0 @@ -param( - [Parameter(Mandatory)] [string] $ProjectPath, - [Parameter(Mandatory)] [string] $TestOutputPath, - [Parameter(Mandatory)] [string] $AgentPath, - [Parameter(Mandatory)] [string] $ResultsPath, - [Parameter(Mandatory)] [string] $ArtifactResultsPath, - [Parameter(Mandatory)] [string] $DotnetPath, - [Parameter(Mandatory)] [string] $NuGetPackagesPath -) - -$ErrorActionPreference = "Stop" -$exitCode = 1 -$previousAgent = $env:DEVOLUTIONS_AGENT_MIGRATION_TEST_EXE -$previousNuGetPackages = $env:NUGET_PACKAGES - -try { - if (-not [System.Security.Principal.WindowsIdentity]::GetCurrent().IsSystem) { - throw "Installer transaction tests must execute as LocalSystem" - } - $nuGetPackageRoot = (Resolve-Path -LiteralPath $NuGetPackagesPath).Path - $separator = [System.IO.Path]::DirectorySeparatorChar.ToString() - if (-not $nuGetPackageRoot.EndsWith($separator, [System.StringComparison]::Ordinal)) { - $nuGetPackageRoot += $separator - } - $env:NUGET_PACKAGES = $nuGetPackageRoot - $testClass = 'DevolutionsAgent.Installer.Tests.PackageBrokerInstallerTests' - $expectedTests = @( - "$testClass.TransactionTestsRunAsLocalSystem" - "$testClass.InstalledAgentConverterUsesAuthoritativeContractBeforePublication" - "$testClass.ConvertedMigrationCommitAndRepeatPreserveOriginalAndEvidence" - "$testClass.RollbackRestoresLegacyArbitrationAndMigrationCanRepeat" - "$testClass.InterruptedPublicationRecoversOnlyOwnedAuthority" - "$testClass.InvalidLegacyPolicyFailsUpgradeAndPreservesSource" - "$testClass.ExistingNewDestinationAndPublicationCollisionArePreserved" - "$testClass.ChangedSourcePreventsDestructiveRollback" - "$testClass.PreexistingAuthorityPreventsLegacyResurrection" - "$testClass.AuthorityCollisionCannotCommitSourceDeletion" - "$testClass.DestinationCollisionCannotCommitSourceDeletion" - "$testClass.UnchangedInputRollbackPreservesLastSurvivingPolicy" - 'DevolutionsAgent.Installer.Tests.InstalledAgentMigrationE2eTests.ConvertedTransactionActivatesAndRetainsManagedAuthority' - ) - $filter = ($expectedTests | ForEach-Object { "FullyQualifiedName=$_" }) -join '|' - New-Item -ItemType Directory -Path $ResultsPath -Force | Out-Null - $trxPath = Join-Path $ResultsPath "installer-system.trx" - if (Test-Path -LiteralPath $trxPath) { - Remove-Item -LiteralPath $trxPath -Force - } - $env:DEVOLUTIONS_AGENT_MIGRATION_TEST_EXE = (Resolve-Path -LiteralPath $AgentPath).Path - & $DotnetPath test $ProjectPath --no-build --no-restore --configuration Debug --framework net48 ` - "-p:OutputPath=$TestOutputPath\" -p:AppendTargetFrameworkToOutputPath=false ` - "-p:NuGetPackageRoot=$env:NUGET_PACKAGES" ` - --filter $filter --logger "trx;LogFileName=installer-system.trx" --results-directory $ResultsPath - $exitCode = $LASTEXITCODE - if ($exitCode -ne 0) { - throw "SYSTEM installer tests exited with $exitCode" - } - [xml] $trx = Get-Content -LiteralPath $trxPath -Raw - $counters = $trx.TestRun.ResultSummary.Counters - $results = @($trx.TestRun.Results.UnitTestResult) - if ($trx.TestRun.ResultSummary.outcome -ne 'Completed' -or - [int] $counters.total -ne $expectedTests.Count -or - [int] $counters.executed -ne $expectedTests.Count -or - [int] $counters.passed -ne $expectedTests.Count -or - [int] $counters.notExecuted -ne 0 -or - $results.Count -ne $expectedTests.Count) { - throw "Expected exactly $($expectedTests.Count) executed, passed SYSTEM installer tests and zero skips: $($counters.OuterXml)" - } - foreach ($name in $expectedTests) { - $matching = @($results | Where-Object { $_.testName -eq $name }) - if ($matching.Count -ne 1 -or $matching[0].outcome -ne 'Passed') { - throw "Required SYSTEM installer test did not pass exactly once: $name" - } - } - New-Item -ItemType Directory -Path $ArtifactResultsPath -Force | Out-Null - Copy-Item -LiteralPath $trxPath -Destination $ArtifactResultsPath -Force - Write-Output "Verified all 12 installer SystemFacts and the installed-Agent migration E2E: 13 passed, zero skipped" -} catch { - Write-Output $_ - if ($exitCode -eq 0) { - $exitCode = 1 - } -} finally { - $env:DEVOLUTIONS_AGENT_MIGRATION_TEST_EXE = $previousAgent - $env:NUGET_PACKAGES = $previousNuGetPackages -} - -exit $exitCode diff --git a/crates/agent-policy-tester/src/windows.rs b/crates/agent-policy-tester/src/windows.rs index c4bae6c78..d5284fb76 100644 --- a/crates/agent-policy-tester/src/windows.rs +++ b/crates/agent-policy-tester/src/windows.rs @@ -20,8 +20,6 @@ use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken, PRO const FULL_POLICY: &str = include_str!("../../now-package-broker/src/assets/samples/corporate-allowlist.policy.json"); const MANAGED_POLICY_RELATIVE_PATH: &str = r"Devolutions\PackageBroker\package-broker-policy.json"; -const MANAGED_AUTHORITY_MARKER: &str = r"Devolutions\PackageBroker\.package-broker-managed-authority.v1"; -const LEGACY_POLICY_RELATIVE_PATH: &str = r"Devolutions\Agent\package-broker-policy.json"; #[cfg(test)] const SECURITY_MANDATORY_LOW_RID: u32 = 0x1000; const SECURITY_MANDATORY_MEDIUM_RID: u32 = 0x2000; @@ -194,7 +192,6 @@ enum Mode { StandardServer, StandardClient, Elevated, - Probe, } impl Mode { @@ -203,8 +200,7 @@ impl Mode { "standard-server" => Ok(Self::StandardServer), "standard-client" => Ok(Self::StandardClient), "elevated" => Ok(Self::Elevated), - "probe" => Ok(Self::Probe), - _ => bail!("unknown mode '{value}'; expected 'standard-server', 'standard-client', 'elevated', or 'probe'"), + _ => bail!("unknown mode '{value}'; expected 'standard-server', 'standard-client', or 'elevated'"), } } } @@ -247,25 +243,8 @@ pub(crate) async fn run() -> anyhow::Result<()> { redirected_policy_paths_fail_closed(&agent_path).await?; management_write_tokens_survive_watcher_reload(&agent_path).await?; managed_policy_lifecycle(&agent_path).await?; - legacy_contract_and_interrupted_repair(&agent_path).await?; + interrupted_malformed_repair(&agent_path).await?; } - Mode::Probe => { - verify_local_system()?; - let pipe_name = next_string(&mut args, "pipe name")?; - let path = next_string(&mut args, "request path")?; - ensure!(args.next().is_none(), "unexpected probe arguments"); - probe(&pipe_name, &path).await?; - } - } - - async fn probe(pipe_name: &str, path: &str) -> anyhow::Result<()> { - let response = request(pipe_name, "GET", path).await?; - let body = response.json()?; - let mut stdout = std::io::stdout().lock(); - serde_json::to_writer(&mut stdout, &json!({ "Status": response.status, "Body": body }))?; - stdout.write_all(b"\n")?; - stdout.flush()?; - Ok(()) } Ok(()) @@ -687,10 +666,8 @@ async fn validate_policy_by_pipe(pipe_name: &str, draft: &Value) -> anyhow::Resu "unexpected validator contract" ); ensure!( - validation["CanonicalDraft"].get("$schema").is_none() - && validation["CanonicalDraft"].get("PolicyVersion").is_none() - && validation["CanonicalDraft"]["PolicyFormatVersion"] == draft["PolicyFormatVersion"], - "canonical draft changed the format version or emitted legacy fields" + validation["CanonicalDraft"]["PolicyFormatVersion"] == draft["PolicyFormatVersion"], + "canonical draft changed the format version" ); Ok(validation) } @@ -936,28 +913,12 @@ async fn strict_contract_validation(pipe_name: &str) -> anyhow::Result<()> { draft["PolicyFormatVersion"] = json!(version); validate_policy_by_pipe(pipe_name, &draft).await?; } - for (field, value, expected_path) in [ - ( - "$schema", - "https://devolutions.net/schemas/now-policy.schema.1.0.json", - "/$schema", - ), - ("PolicyVersion", "1.0.0", "/PolicyVersion"), - ("PolicyFormatVersion", "2.0.0", "/PolicyFormatVersion"), - ("PolicyFormatVersion", "broken", "/PolicyFormatVersion"), - ] { + for (value, expected_path) in [("2.0.0", "/PolicyFormatVersion"), ("broken", "/PolicyFormatVersion")] { let mut draft = policy_draft("tests.contract", "Contract"); - draft[field] = json!(value); + draft["PolicyFormatVersion"] = json!(value); assert_invalid_draft(pipe_name, draft, expected_path).await?; } - let mut legacy = policy_draft("tests.contract", "Contract"); - legacy - .as_object_mut() - .context("draft is not an object")? - .remove("PolicyFormatVersion"); - legacy["PolicyVersion"] = json!("1.0.0"); - legacy["$schema"] = json!("https://devolutions.net/schemas/now-policy.schema.1.0.json"); - assert_invalid_draft(pipe_name, legacy, "/PolicyVersion").await + Ok(()) } async fn assert_invalid_draft(pipe_name: &str, draft: Value, expected_path: &str) -> anyhow::Result<()> { @@ -1022,11 +983,6 @@ async fn managed_policy_lifecycle(agent_path: &Path) -> anyhow::Result<()> { created["Policy"]["Metadata"]["Revision"] == 1, "Create did not assign revision 1" ); - let authority_marker = agent.data_dir.path().join(MANAGED_AUTHORITY_MARKER); - ensure!( - authority_marker.is_file() && std::fs::metadata(&authority_marker)?.len() == 0, - "Create did not establish durable managed authority" - ); wait_for_log(&agent, "Policy creation succeeded").await?; let updated = replace_policy( @@ -1143,30 +1099,6 @@ async fn managed_policy_lifecycle(agent_path: &Path) -> anyhow::Result<()> { restarted.json()?["Policy"] == confirmed["Policy"], "restart changed the active managed policy" ); - ensure!( - authority_marker.is_file() && policy_management(&agent).await?["Source"] == "DefaultPath", - "restart lost durable managed authority" - ); - - agent.stop().await?; - let legacy_path = agent.data_dir.path().join(LEGACY_POLICY_RELATIVE_PATH); - let legacy_dir = legacy_path.parent().context("legacy policy path has no parent")?; - std::fs::create_dir_all(legacy_dir).context("create isolated legacy policy directory")?; - secure_policy_path(legacy_dir, true)?; - std::fs::write(&legacy_path, serde_json::to_vec_pretty(&empty_policy())?) - .context("write isolated legacy policy")?; - secure_policy_path(&legacy_path, false)?; - std::fs::remove_file(&agent.policy_path).context("remove managed policy before authority restart")?; - agent.start_again(agent_path).await?; - let authority = policy_management(&agent).await?; - ensure!( - authority["State"] == "Missing" && authority["Source"] == "DefaultPath", - "durable managed authority allowed legacy policy rollback" - ); - ensure!( - request(&agent.pipe_name, "GET", "/v1/policy").await?.status == 404, - "legacy policy became active after managed authority was established" - ); Ok(()) } @@ -1175,7 +1107,7 @@ async fn warnings_identity_and_receipts( agent_path: &Path, current: &Value, ) -> anyhow::Result { - let mut draft = policy_draft("tests.replaced-identity", "Compatible contract"); + let mut draft = policy_draft("tests.replaced-identity", "Canonical contract"); draft["PolicyFormatVersion"] = json!("1.7.3"); draft["Enforcement"]["AuditMode"] = json!(true); let validation = validate_policy_by_pipe(&agent.pipe_name, &draft).await?; @@ -1225,10 +1157,8 @@ async fn warnings_identity_and_receipts( ensure!( replaced["Policy"]["Metadata"]["Id"] == "tests.replaced-identity" && replaced["Policy"]["Metadata"]["Revision"] == 1 - && replaced["Policy"]["PolicyFormatVersion"] == "1.7.3" - && replaced["Policy"].get("$schema").is_none() - && replaced["Policy"].get("PolicyVersion").is_none(), - "ReplaceIdentity did not preserve the compatible contract and reset revision" + && replaced["Policy"]["PolicyFormatVersion"] == "1.7.3", + "ReplaceIdentity did not preserve the canonical contract and reset revision" ); wait_for_log(agent, "Policy change succeeded").await?; wait_for_log(agent, "replace_identity").await?; @@ -1237,79 +1167,49 @@ async fn warnings_identity_and_receipts( Ok(replaced) } -async fn legacy_contract_and_interrupted_repair(agent_path: &Path) -> anyhow::Result<()> { - let mut legacy = empty_policy(); - legacy - .as_object_mut() - .context("policy is not an object")? - .remove("PolicyFormatVersion"); - legacy["$schema"] = json!("https://devolutions.net/schemas/now-policy.schema.1.0.json"); - legacy["PolicyVersion"] = json!("1.0.0"); - let mut mixed = legacy.clone(); - mixed["PolicyFormatVersion"] = json!("1.0.0"); - for original in [ - serde_json::to_vec(&legacy)?, - serde_json::to_vec(&mixed)?, - b"malformed-policy-secret-marker".to_vec(), - ] { - for marker_staging in [false, true] { - let data_dir = create_data_dir()?; - let policy_path = data_dir.path().join("policy.json"); - std::fs::write(&policy_path, &original)?; - secure_policy_path(&policy_path, false)?; - let prefix = ".policy.json.txn-11111111-2222-4333-8444-555555555555"; - let new_path = data_dir.path().join(format!("{prefix}.new")); - std::fs::write(&new_path, b"partial replacement")?; - secure_policy_path(&new_path, false)?; - let marker_path = data_dir.path().join(format!("{prefix}.marker.prepare")); - if marker_staging { - std::fs::write(&marker_path, br#"{"Version":"#)?; - secure_policy_path(&marker_path, false)?; - } - let mut agent = - AgentHarness::start_with_path(agent_path, data_dir, unique_pipe_name(), policy_path).await?; - let mut invalid = policy_management(&agent).await?; - ensure!( - invalid["State"] == "Invalid" && invalid["WriteCapability"] == "Writable", - "interrupted Repair did not retain a repairable invalid original: {invalid}" - ); - ensure!( - std::fs::read(&agent.policy_path)? == original && !new_path.exists() && !marker_path.exists(), - "recovery changed the original or retained prepublication remnants" - ); - ensure!( - request(&agent.pipe_name, "GET", "/v1/policy").await?.status == 404, - "legacy, mixed, or malformed policy became active" - ); - if original.starts_with(b"{") { - ensure!( - invalid["InvalidDiagnostics"]["Findings"] - .as_array() - .is_some_and(|findings| findings - .iter() - .any(|finding| finding["Code"] == "UnsupportedPolicyFormatVersion")), - "legacy contract did not produce its strict diagnostic" - ); - std::fs::write(&agent.policy_path, serde_json::to_vec(&empty_policy())?)?; - wait_for_management(&agent, |management| management["State"] == "Active").await?; - std::fs::write(&agent.policy_path, &original)?; - invalid = wait_for_management(&agent, |management| management["State"] == "Invalid").await?; - wait_for_log(&agent, "legacy_policy_contract").await?; - } - let repaired = replace_policy( - &agent, - "Repair", - invalid["StoreToken"].clone(), - policy_draft("tests.interrupted-repair", "Recovered"), - ) - .await?; - agent.restart(agent_path).await?; - ensure!( - policy_management(&agent).await?["Policy"] == repaired["Policy"], - "repaired policy did not survive restart" - ); - agent.stop().await?; +async fn interrupted_malformed_repair(agent_path: &Path) -> anyhow::Result<()> { + let original = b"malformed-policy-secret-marker"; + for marker_staging in [false, true] { + let data_dir = create_data_dir()?; + let policy_path = data_dir.path().join("policy.json"); + std::fs::write(&policy_path, original)?; + secure_policy_path(&policy_path, false)?; + let prefix = ".policy.json.txn-11111111-2222-4333-8444-555555555555"; + let new_path = data_dir.path().join(format!("{prefix}.new")); + std::fs::write(&new_path, b"partial replacement")?; + secure_policy_path(&new_path, false)?; + let marker_path = data_dir.path().join(format!("{prefix}.marker.prepare")); + if marker_staging { + std::fs::write(&marker_path, br#"{"Version":"#)?; + secure_policy_path(&marker_path, false)?; } + let mut agent = AgentHarness::start_with_path(agent_path, data_dir, unique_pipe_name(), policy_path).await?; + let invalid = policy_management(&agent).await?; + ensure!( + invalid["State"] == "Invalid" && invalid["WriteCapability"] == "Writable", + "interrupted Repair did not retain a repairable invalid original: {invalid}" + ); + ensure!( + std::fs::read(&agent.policy_path)? == original && !new_path.exists() && !marker_path.exists(), + "recovery changed the original or retained prepublication remnants" + ); + ensure!( + request(&agent.pipe_name, "GET", "/v1/policy").await?.status == 404, + "malformed policy became active" + ); + let repaired = replace_policy( + &agent, + "Repair", + invalid["StoreToken"].clone(), + policy_draft("tests.interrupted-repair", "Recovered"), + ) + .await?; + agent.restart(agent_path).await?; + ensure!( + policy_management(&agent).await?["Policy"] == repaired["Policy"], + "repaired policy did not survive restart" + ); + agent.stop().await?; } Ok(()) } @@ -1499,8 +1399,6 @@ mod tests { fn policy_fixtures_use_the_current_contract() { for policy in [full_policy(), empty_policy(), policy_draft("tests.contract", "Test")] { assert_eq!(policy["PolicyFormatVersion"], "1.0.0"); - assert!(policy.get("$schema").is_none()); - assert!(policy.get("PolicyVersion").is_none()); } } @@ -1515,11 +1413,6 @@ mod tests { } } - #[test] - fn probe_mode_is_only_available_to_a_local_system_client() { - assert!(matches!(Mode::parse("probe"), Ok(Mode::Probe))); - } - #[test] fn standard_user_token_requires_medium_integrity() { validate_standard_user_token( diff --git a/package/AgentWindowsManaged.Tests/InstalledAgentMigrationE2eTests.cs b/package/AgentWindowsManaged.Tests/InstalledAgentMigrationE2eTests.cs deleted file mode 100644 index 0db6e859d..000000000 --- a/package/AgentWindowsManaged.Tests/InstalledAgentMigrationE2eTests.cs +++ /dev/null @@ -1,453 +0,0 @@ -using DevolutionsAgent.Actions; -using DevolutionsAgent.Resources; -using Microsoft.Deployment.WindowsInstaller; -using Microsoft.Win32.SafeHandles; -using Newtonsoft.Json.Linq; -using System; -using System.ComponentModel; -using System.Diagnostics; -using System.IO; -using System.Runtime.InteropServices; -using System.Security.AccessControl; -using System.Security.Principal; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Xunit; -using Xunit.Abstractions; - -namespace DevolutionsAgent.Installer.Tests; - -public sealed class InstalledAgentMigrationE2eTests -{ - private const string CurrentPolicy = - """{"PolicyFormatVersion":"1.7.3","PolicyType":"PackageBrokerPolicy","Metadata":{"Id":"installer-e2e","Publisher":"Test","Revision":17,"PublishedAt":"2026-01-01T00:00:00Z"},"Enforcement":{"DefaultDecision":"Deny","RulePrecedence":"PriorityThenDeny"},"Rules":[]}"""; - private readonly ITestOutputHelper output; - - public InstalledAgentMigrationE2eTests(ITestOutputHelper output) => this.output = output; - - [Theory] - [InlineData("DevolutionsAgent.exe")] - [InlineData("devolutionsagent.exe")] - public void AgentExecutableNameComparisonIsCaseInsensitive(string fileName) - { - Assert.True(IsExpectedAgentExecutableName(fileName)); - } - - [Fact] - public void AgentJobTerminatesChildWhenScopeThrows() - { - using Process child = Process.Start(new ProcessStartInfo( - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), - @"WindowsPowerShell\v1.0\powershell.exe"), - "-NoProfile -NonInteractive -Command Start-Sleep -Seconds 60") - { - UseShellExecute = false, - CreateNoWindow = true, - }); - try - { - InvalidOperationException failure = new("simulate a failed Agent assertion"); - void FailWithAssignedChild() - { - using AgentJob job = new(); - job.Assign(child); - throw failure; - } - Assert.Same(failure, Assert.Throws(FailWithAssignedChild)); - Assert.True(child.WaitForExit(10000), "Job disposal left the child running"); - } - finally - { - if (!child.HasExited) - { - child.Kill(); - Assert.True(child.WaitForExit(10000), "Cleanup did not stop the child"); - } - } - } - - [PackageBrokerInstallerTests.SystemFact] - public void ConvertedTransactionActivatesAndRetainsManagedAuthority() - { - Assert.True(WindowsIdentity.GetCurrent().IsSystem); - string agent = Environment.GetEnvironmentVariable("DEVOLUTIONS_AGENT_MIGRATION_TEST_EXE"); - Assert.False(string.IsNullOrWhiteSpace(agent), "The SYSTEM runner must supply the built Agent executable"); - Assert.True(File.Exists(agent), agent); - Assert.True(IsExpectedAgentExecutableName(Path.GetFileName(agent)), agent); - using PackageBrokerPolicyActions.PinnedPath installedAgent = - PackageBrokerPolicyActions.PinPathWithoutReparse( - agent, leafIsDirectory: false, allowMissingLeaf: false, - leafAccess: WinAPI.GENERIC_READ | WinAPI.READ_CONTROL, verifyTrustedAncestors: true); - Assert.True( - PackageBrokerPolicyActions.TryVerifyLegacyPolicySourceSecurity( - File.GetAccessControl(agent), out string agentSecurityDiagnostic), - agentSecurityDiagnostic); - string root = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), - $"dgw-installer-e2e-{Guid.NewGuid():N}"); - PackageBrokerPolicyActions.CreateDirectoryWithSecurity(root, Includes.PROGRAM_DATA_PACKAGE_BROKER_SDDL); - try - { - PackageBrokerPolicyActions.VerifyPackageBrokerSecurity(Directory.GetAccessControl(root)); - string vendor = Path.Combine(root, "Devolutions"); - string legacyDirectory = Path.Combine(vendor, "Agent"); - string managedDirectory = Path.Combine(vendor, "PackageBroker"); - foreach (string directory in new[] { vendor, legacyDirectory, managedDirectory }) - { - PackageBrokerPolicyActions.CreateDirectoryWithSecurity( - directory, Includes.PROGRAM_DATA_PACKAGE_BROKER_SDDL); - } - - string source = Path.Combine(legacyDirectory, "package-broker-policy.json"); - string destination = Path.Combine(managedDirectory, "package-broker-policy.json"); - string marker = Path.Combine(managedDirectory, ".installer-e2e.migration"); - string authority = Path.Combine(managedDirectory, ".package-broker-managed-authority.v1"); - byte[] original = Encoding.UTF8.GetBytes(CurrentPolicy.Replace( - "\"PolicyFormatVersion\":", - "\"$schema\":\"https://devolutions.net/schemas/now-policy.schema.1.0.json\",\"PolicyVersion\":")); - File.WriteAllBytes(source, original); - FileSecurity security = new(); - security.SetSecurityDescriptorSddlForm(Includes.PROGRAM_DATA_PACKAGE_BROKER_FILE_SDDL); - File.SetAccessControl(source, security); - - int conversions = 0; - void Migrate() - { - Assert.Equal(ActionResult.Success, PackageBrokerPolicyActions.MigrateLegacyPolicy( - output.WriteLine, source, destination, marker, input => - { - Assert.False(File.Exists(destination)); - Assert.False(File.Exists(authority)); - Assert.Equal(original, input); - byte[] converted = PackageBrokerPolicyActions.ConvertWithInstalledAgent( - Path.GetDirectoryName(agent), input); - Assert.Equal(CurrentPolicy, Encoding.UTF8.GetString(converted)); - conversions++; - return converted; - })); - Assert.Equal(CurrentPolicy, File.ReadAllText(destination)); - Assert.True(File.Exists(authority)); - Assert.Empty(File.ReadAllBytes(authority)); - AssertPreserved(); - foreach (string path in new[] { destination, authority, marker, marker + ".original" }) - { - PackageBrokerPolicyActions.VerifyPackageBrokerSecurity(File.GetAccessControl(path)); - } - } - void AssertPreserved() - { - Assert.Equal(original, File.ReadAllBytes(source)); - Assert.Equal(original, File.ReadAllBytes(marker + ".original")); - Assert.True(File.Exists(marker)); - } - - Migrate(); - Assert.Equal(ActionResult.Success, PackageBrokerPolicyActions.RollbackLegacyPolicy( - output.WriteLine, source, destination, marker)); - Assert.False(File.Exists(destination)); - Assert.False(File.Exists(authority)); - AssertPreserved(); - Migrate(); - Assert.Equal(2, conversions); - PackageBrokerPolicyActions.CommitLegacyPolicy(output.WriteLine, source, destination, marker); - AssertPreserved(); - - using AgentProcess running = new(agent, root, output); - running.Start(); - JToken active = AssertActive(running, destination); - running.Stop(); - running.Start(); - Assert.True(JToken.DeepEquals(active, AssertActive(running, destination))); - AssertPreserved(); - running.Stop(); - File.Delete(destination); - running.Start(); - JObject management = running.Get("/v1/policy/management", 200)["Management"] as JObject; - Assert.NotNull(management); - Assert.Equal("DefaultPath", (string)management["Source"]); - Assert.Equal("Missing", (string)management["State"]); - Assert.Equal("active policy is unavailable", (string)running.Get("/v1/policy", 404)["Message"]); - Assert.True(File.Exists(authority)); - AssertPreserved(); - } - finally - { - for (int attempt = 0; ; attempt++) - { - try - { - Directory.Delete(root, recursive: true); - break; - } - catch (IOException) when (attempt < 19) - { - Thread.Sleep(250); - } - } - } - } - - private static JToken AssertActive(AgentProcess agent, string destination) - { - JObject response = agent.Get("/v1/policy", 200); - JToken policy = response["Policy"]; - Assert.NotNull(policy); - Assert.Equal("1.7.3", (string)policy["PolicyFormatVersion"]); - Assert.Equal("PackageBrokerPolicy", (string)policy["PolicyType"]); - Assert.Null(policy["PolicyVersion"]); - Assert.Null(policy["$schema"]); - Assert.Equal("installer-e2e", (string)policy["Metadata"]["Id"]); - Assert.Equal("Test", (string)policy["Metadata"]["Publisher"]); - Assert.Equal(17, (int)policy["Metadata"]["Revision"]); - Assert.Equal( - JObject.Parse(CurrentPolicy)["Metadata"]["PublishedAt"], - policy["Metadata"]["PublishedAt"]); - Assert.True(JToken.DeepEquals(JObject.Parse(CurrentPolicy)["Enforcement"], policy["Enforcement"])); - Assert.True(JToken.DeepEquals(new JArray(), policy["Rules"])); - Assert.Equal(CurrentPolicy, File.ReadAllText(destination)); - JObject management = agent.Get("/v1/policy/management", 200)["Management"] as JObject; - Assert.NotNull(management); - Assert.Equal("DefaultPath", (string)management["Source"]); - Assert.Equal("Active", (string)management["State"]); - return policy; - } - - private static bool IsExpectedAgentExecutableName(string fileName) => - string.Equals(Includes.EXECUTABLE_NAME, fileName, StringComparison.OrdinalIgnoreCase); - - private sealed class AgentProcess : IDisposable - { - private readonly string executable; - private readonly string tester; - private readonly string root; - private readonly ITestOutputHelper output; - private readonly string pipeName = $"Devolutions.Now.PackageBroker.installer-e2e.{Guid.NewGuid():N}"; - private readonly AgentJob job; - private Process process; - private Task stdout; - private Task stderr; - - internal AgentProcess(string executable, string root, ITestOutputHelper output) - { - this.executable = executable; - tester = Environment.GetEnvironmentVariable("AGENT_POLICY_TESTER_E2E_EXE"); - Assert.False(string.IsNullOrWhiteSpace(tester), "The SYSTEM runner must supply the policy tester executable"); - Assert.True(File.Exists(tester), tester); - this.root = root; - this.output = output; - JObject config = new() - { - ["LogFile"] = Path.Combine(root, "agent-installer-e2e"), - ["PackageBroker"] = new JObject - { - ["Enabled"] = true, - ["PipeName"] = @"\\.\pipe\" + pipeName, - }, - ["__debug__"] = new JObject { ["skip_broker_signature_validation"] = true }, - }; - File.WriteAllText(Path.Combine(root, "agent.json"), config.ToString()); - job = new AgentJob(); - } - - internal void Start() - { - Assert.Null(process); - stdout = null; - stderr = null; - ProcessStartInfo start = new(executable, "run") - { - UseShellExecute = false, - CreateNoWindow = true, - WorkingDirectory = root, - RedirectStandardOutput = true, - RedirectStandardError = true, - }; - start.EnvironmentVariables["DAGENT_CONFIG_PATH"] = root; - start.EnvironmentVariables["ProgramData"] = root; - process = Process.Start(start); - job.Assign(process); - stdout = process.StandardOutput.ReadToEndAsync(); - stderr = process.StandardError.ReadToEndAsync(); - Stopwatch timer = Stopwatch.StartNew(); - while (true) - { - Assert.False(process.HasExited, "Agent exited before its broker became ready"); - try - { - Get("/v1/health", 200); - return; - } - catch (TimeoutException) when (timer.Elapsed < TimeSpan.FromSeconds(20)) - { - Thread.Sleep(50); - } - catch (IOException) when (timer.Elapsed < TimeSpan.FromSeconds(20)) - { - Thread.Sleep(50); - } - } - } - - internal JObject Get(string path, int expectedStatus) - { - ProcessStartInfo start = new(tester, $"\"{tester}\" probe \"\\\\.\\pipe\\{pipeName}\" \"{path}\"") - { - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - }; - using Process client = Process.Start(start); - Task stdout = client.StandardOutput.ReadToEndAsync(); - Task stderr = client.StandardError.ReadToEndAsync(); - if (!client.WaitForExit(10000)) - { - client.Kill(); - throw new TimeoutException($"timed out probing {path}"); - } - string standardOutput = stdout.GetAwaiter().GetResult(); - string standardError = stderr.GetAwaiter().GetResult(); - if (client.ExitCode != 0) - { - throw new IOException($"policy tester probe failed for {path}: {standardError}"); - } - JObject response = JObject.Parse(standardOutput); - Assert.Equal(expectedStatus, (int)response["Status"]); - return response["Body"] as JObject; - } - - internal void Stop() - { - if (process == null) - { - return; - } - try - { - if (!process.HasExited) - { - try - { - process.Kill(); - } - catch (InvalidOperationException) when (process.HasExited) - { - } - catch (Win32Exception) when (process.WaitForExit(10000)) - { - } - } - Assert.True(process.WaitForExit(10000), "Agent did not stop"); - if (stdout != null) - { - output.WriteLine(stdout.GetAwaiter().GetResult()); - output.WriteLine(stderr.GetAwaiter().GetResult()); - } - foreach (string log in Directory.GetFiles(root, "agent-installer-e2e*")) - { - output.WriteLine(File.ReadAllText(log)); - } - } - finally - { - if (process.HasExited) - { - process.Dispose(); - process = null; - } - } - } - - public void Dispose() - { - job.Dispose(); - Stop(); - } - } - - private sealed class AgentJob : IDisposable - { - private const uint JobObjectLimitKillOnJobClose = 0x2000; - private const int JobObjectExtendedLimitInformation = 9; - private readonly SafeFileHandle handle; - - internal AgentJob() - { - handle = CreateJobObjectW(IntPtr.Zero, null); - if (handle.IsInvalid) - { - throw new Win32Exception(Marshal.GetLastWin32Error()); - } - ExtendedLimitInformation limits = new() - { - BasicLimitInformation = new BasicLimitInformation { LimitFlags = JobObjectLimitKillOnJobClose }, - }; - if (!SetInformationJobObject( - handle, JobObjectExtendedLimitInformation, ref limits, Marshal.SizeOf())) - { - int error = Marshal.GetLastWin32Error(); - handle.Dispose(); - throw new Win32Exception(error); - } - } - - internal void Assign(Process process) - { - if (!AssignProcessToJobObject(handle, process.Handle)) - { - throw new Win32Exception(Marshal.GetLastWin32Error()); - } - } - - public void Dispose() => handle.Dispose(); - - [StructLayout(LayoutKind.Sequential)] - private struct BasicLimitInformation - { - internal long PerProcessUserTimeLimit; - internal long PerJobUserTimeLimit; - internal uint LimitFlags; - internal UIntPtr MinimumWorkingSetSize; - internal UIntPtr MaximumWorkingSetSize; - internal uint ActiveProcessLimit; - internal UIntPtr Affinity; - internal uint PriorityClass; - internal uint SchedulingClass; - } - - [StructLayout(LayoutKind.Sequential)] - private struct IoCounters - { - internal ulong ReadOperationCount; - internal ulong WriteOperationCount; - internal ulong OtherOperationCount; - internal ulong ReadTransferCount; - internal ulong WriteTransferCount; - internal ulong OtherTransferCount; - } - - [StructLayout(LayoutKind.Sequential)] - private struct ExtendedLimitInformation - { - internal BasicLimitInformation BasicLimitInformation; - internal IoCounters IoInfo; - internal UIntPtr ProcessMemoryLimit; - internal UIntPtr JobMemoryLimit; - internal UIntPtr PeakProcessMemoryUsed; - internal UIntPtr PeakJobMemoryUsed; - } - - [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern SafeFileHandle CreateJobObjectW(IntPtr attributes, string name); - - [DllImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool SetInformationJobObject( - SafeFileHandle job, int informationClass, ref ExtendedLimitInformation information, int length); - - [DllImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool AssignProcessToJobObject(SafeFileHandle job, IntPtr process); - } -} From 04ba6d9eda3283121d0d4eb88dd7d081aa934ff4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 00:25:08 +0900 Subject: [PATCH 12/39] test(agent): cover final policy contract Exercise the final canonical policy rule shapes and validation semantics through the end-to-end policy management harness. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/agent-policy-tester/src/windows.rs | 75 +++++++++++++++++++++-- 1 file changed, 71 insertions(+), 4 deletions(-) diff --git a/crates/agent-policy-tester/src/windows.rs b/crates/agent-policy-tester/src/windows.rs index d5284fb76..bc5f51cb4 100644 --- a/crates/agent-policy-tester/src/windows.rs +++ b/crates/agent-policy-tester/src/windows.rs @@ -481,7 +481,49 @@ fn policy_draft(id: &str, publisher: &str) -> Value { "PolicyFormatVersion": "1.0.0", "Metadata": { "Id": id, "Publisher": publisher }, "Enforcement": { "DefaultDecision": "Deny" }, - "Rules": [] + "Rules": [ + { + "Id": "allow.exact", + "Priority": 100, + "Decision": "Allow", + "Match": { + "Operations": ["Install"], + "Managers": ["Winget"], + "SourceNames": ["winget"], + "PackageIdentifiers": { "Exact": ["Microsoft.PowerToys"] }, + "Version": { "Exact": ["0.86.0"] }, + "ExecutionElevation": ["Elevated"], + "Interactive": true, + "SkipHashCheck": false + }, + "Constraints": { + "AllowInteractive": true, + "AllowSkipHashCheck": false + } + }, + { + "Id": "allow.pattern", + "Priority": 101, + "Decision": "Allow", + "Match": { + "Managers": ["Winget"], + "SourceNames": ["winget"], + "PackageIdentifiers": { "Patterns": ["Contoso.*"] }, + "Version": { + "Range": { + "MinVersion": "1.0.0", + "MaxVersion": "2.0.0", + "IncludePrerelease": false + } + }, + "ExecutionElevation": ["Standard"], + "HasCustomParameters": false + }, + "Constraints": { + "AllowCustomParameters": false + } + } + ] }) } @@ -662,7 +704,7 @@ async fn validate_policy_by_pipe(pipe_name: &str, draft: &Value) -> anyhow::Resu let validation = validation_response.json()?["Validation"].clone(); ensure!(validation["IsValid"] == true, "policy validation failed"); ensure!( - validation["ValidatorVersion"] == "now-package-broker-policy-validator/9", + validation["ValidatorVersion"] == "now-package-broker-policy-validator/10", "unexpected validator contract" ); ensure!( @@ -911,13 +953,38 @@ async fn strict_contract_validation(pipe_name: &str) -> anyhow::Result<()> { for version in ["1.0.0", "1.7.3"] { let mut draft = policy_draft("tests.contract", "Contract"); draft["PolicyFormatVersion"] = json!(version); - validate_policy_by_pipe(pipe_name, &draft).await?; + let validation = validate_policy_by_pipe(pipe_name, &draft).await?; + let canonical = &validation["CanonicalDraft"]; + ensure!( + canonical["Rules"][0]["Match"]["Managers"] == json!(["Winget"]) + && canonical["Rules"][0]["Match"]["SourceNames"] == json!(["winget"]) + && canonical["Rules"][0]["Match"]["PackageIdentifiers"]["Exact"] == json!(["Microsoft.PowerToys"]) + && canonical["Rules"][0]["Match"]["Version"]["Exact"] == json!(["0.86.0"]) + && canonical["Rules"][0]["Match"]["ExecutionElevation"] == json!(["Elevated"]) + && canonical["Rules"][1]["Match"]["PackageIdentifiers"]["Patterns"] == json!(["Contoso.*"]) + && canonical["Rules"][1]["Match"]["Version"]["Range"]["MinVersion"] == "1.0.0" + && canonical["Rules"][1]["Match"]["ExecutionElevation"] == json!(["Standard"]), + "canonical draft did not preserve final rule shapes" + ); + ensure!( + canonical["Rules"][0]["Match"].get("PreRelease").is_none() + && canonical["Rules"][1]["Match"].get("Interactive").is_none(), + "canonical draft did not omit absent optional characteristics" + ); } for (value, expected_path) in [("2.0.0", "/PolicyFormatVersion"), ("broken", "/PolicyFormatVersion")] { let mut draft = policy_draft("tests.contract", "Contract"); draft["PolicyFormatVersion"] = json!(value); assert_invalid_draft(pipe_name, draft, expected_path).await?; } + let mut invalid_interval = policy_draft("tests.contract", "Contract"); + invalid_interval["Metadata"]["ValidFrom"] = json!("2026-01-01T00:00:00Z"); + invalid_interval["Metadata"]["ValidUntil"] = json!("2026-01-01T00:00:00Z"); + assert_invalid_draft(pipe_name, invalid_interval, "/Metadata/ValidUntil").await?; + + let mut duplicate_rule = policy_draft("tests.contract", "Contract"); + duplicate_rule["Rules"][1]["Id"] = duplicate_rule["Rules"][0]["Id"].clone(); + assert_invalid_draft(pipe_name, duplicate_rule, "/Rules/1/Id").await?; Ok(()) } @@ -945,7 +1012,7 @@ async fn assert_invalid_draft(pipe_name: &str, draft: Value, expected_path: &str ensure!( invalid_validation.get("CanonicalDraft").is_none() && invalid_validation.get("ValidationReceipt").is_none() - && invalid_validation["ValidatorVersion"] == "now-package-broker-policy-validator/9", + && invalid_validation["ValidatorVersion"] == "now-package-broker-policy-validator/10", "invalid draft returned a canonical draft, receipt, or wrong validator version" ); ensure!( From 26152e373fc37e3acac1b11139a95f83d795d5de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 05:33:00 +0900 Subject: [PATCH 13/39] test(agent): cover policy review gaps Exercise standard-user read access, stale overwrite rejection, and LocalSystem shutdown verification after failed readiness. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/agent-policy-tester/run-unelevated.ps1 | 128 ++++++++++++------ crates/agent-policy-tester/src/windows.rs | 22 ++- 2 files changed, 108 insertions(+), 42 deletions(-) diff --git a/crates/agent-policy-tester/run-unelevated.ps1 b/crates/agent-policy-tester/run-unelevated.ps1 index 5dc9b2b2b..b6615393c 100644 --- a/crates/agent-policy-tester/run-unelevated.ps1 +++ b/crates/agent-policy-tester/run-unelevated.ps1 @@ -108,6 +108,64 @@ function Remove-StagingPath { } } +function Complete-ServerShutdown { + param( + [bool] $ServerLaunchAttempted, + [bool] $ServerLaunchExplicitlyFailed, + [string] $StopPath, + [string] $StatusPath, + [string] $ServerOutputPath, + [string] $OutputPath, + [int] $ExitCode, + [scriptblock] $SignalServer + ) + + if (-not $ServerLaunchAttempted) { + return $ExitCode + } + + $signal = & $SignalServer + $signal.Output | Out-File $OutputPath -Append + if ($signal.ExitCode -ne 0 -or -not (Test-Path -LiteralPath $StopPath)) { + try { + New-Item -ItemType File -Path $StopPath -ErrorAction Stop | Out-Null + "Created the stop marker directly after LocalSystem signaling did not confirm it" | Out-File $OutputPath -Append + } catch { + $_ | Out-File $OutputPath -Append + } + } + if (-not (Test-Path -LiteralPath $StopPath)) { + "Failed to create the LocalSystem test server stop marker" | Out-File $OutputPath -Append + $ExitCode = 1 + } + + if (-not $ServerLaunchExplicitlyFailed) { + $deadline = [DateTime]::UtcNow.AddSeconds(30) + while (-not (Test-Path -LiteralPath $StatusPath) -and [DateTime]::UtcNow -lt $deadline) { + Start-Sleep -Milliseconds 100 + } + if (Test-Path -LiteralPath $ServerOutputPath) { + Get-Content -LiteralPath $ServerOutputPath | Out-File $OutputPath -Append + } + if (Test-Path -LiteralPath $StatusPath) { + try { + $serverExitCode = Read-ServerStatus -Path $StatusPath + if ($serverExitCode -ne 0 -and $ExitCode -eq 0) { + $ExitCode = $serverExitCode + } + } catch { + $_ | Out-File $OutputPath -Append + $ExitCode = 1 + } + } else { + "Timed out waiting for LocalSystem test server shutdown" | Out-File $OutputPath -Append + $ExitCode = 1 + } + } + + return $ExitCode +} + function New-RandomSecurePassword { $alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*" $bytes = [byte[]]::new(32) @@ -271,6 +329,7 @@ function Invoke-RunnerSelfTests { try { $ready = Join-Path $root "ready.json" $status = Join-Path $root "status" + $serverOutput = Join-Path $root "server.out" foreach ($expected in @(0, 1, -1)) { Publish-ServerStatus -Path $status -ExitCode $expected if ((Read-ServerStatus -Path $status) -ne $expected) { @@ -383,6 +442,27 @@ function Invoke-RunnerSelfTests { } } + $stop = Join-Path $root "stop" + $shutdownOutput = Join-Path $root "shutdown.out" + $signalState = [pscustomobject]@{ Count = 0 } + [System.IO.File]::WriteAllText($status, "invalid") + $readinessFailureExitCode = Complete-ServerShutdown ` + -ServerLaunchAttempted $true -ServerLaunchExplicitlyFailed $false ` + -StopPath $stop -StatusPath $status -ServerOutputPath $serverOutput -OutputPath $shutdownOutput ` + -ExitCode 1 -SignalServer { + $signalState.Count++ + New-Item -ItemType File -Path $stop | Out-Null + [pscustomobject]@{ ExitCode = 0; Output = @("Simulated LocalSystem signal") } + } + if ( + $readinessFailureExitCode -ne 1 -or + $signalState.Count -ne 1 -or + -not (Test-Path -LiteralPath $stop) -or + (Get-Content -LiteralPath $shutdownOutput -Raw) -notmatch "published an invalid completion status" + ) { + throw "Readiness failure did not signal and verify LocalSystem server shutdown" + } + Remove-StagingPath -Path (Join-Path $root "already-absent") } finally { Remove-StagingPath -Path $root @@ -550,48 +630,14 @@ try { $_ | Out-File $outputPath -Append $exitCode = 1 } finally { - if ($serverLaunchAttempted) { - $signalOutput = & psexec.exe -accepteula -s pwsh.exe -NoProfile -File $PSCommandPath ` - -Action Signal -StopPath $stopPath 2>&1 - $signalExitCode = $LASTEXITCODE - $signalOutput | Out-File $outputPath -Append - if ($signalExitCode -ne 0 -and -not (Test-Path -LiteralPath $stopPath)) { - try { - New-Item -ItemType File -Path $stopPath -ErrorAction Stop | Out-Null - "Created the stop marker directly after SYSTEM signaling failed" | Out-File $outputPath -Append - } catch { - $_ | Out-File $outputPath -Append - } + $exitCode = Complete-ServerShutdown ` + -ServerLaunchAttempted $serverLaunchAttempted -ServerLaunchExplicitlyFailed $serverLaunchExplicitlyFailed ` + -StopPath $stopPath -StatusPath $statusPath -ServerOutputPath $serverOutputPath -OutputPath $outputPath ` + -ExitCode $exitCode -SignalServer { + $signalOutput = & psexec.exe -accepteula -s pwsh.exe -NoProfile -File $PSCommandPath ` + -Action Signal -StopPath $stopPath 2>&1 + [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = $signalOutput } } - if (-not (Test-Path -LiteralPath $stopPath) -and $exitCode -eq 0) { - "Failed to create the LocalSystem test server stop marker" | Out-File $outputPath -Append - $exitCode = 1 - } - - if (-not $serverLaunchExplicitlyFailed) { - $deadline = [DateTime]::UtcNow.AddSeconds(30) - while (-not (Test-Path -LiteralPath $statusPath) -and [DateTime]::UtcNow -lt $deadline) { - Start-Sleep -Milliseconds 100 - } - if (Test-Path -LiteralPath $serverOutputPath) { - Get-Content -LiteralPath $serverOutputPath | Out-File $outputPath -Append - } - if (Test-Path -LiteralPath $statusPath) { - try { - $serverExitCode = Read-ServerStatus -Path $statusPath - if ($serverExitCode -ne 0 -and $exitCode -eq 0) { - $exitCode = $serverExitCode - } - } catch { - $_ | Out-File $outputPath -Append - $exitCode = 1 - } - } elseif ($exitCode -eq 0) { - "Timed out waiting for LocalSystem test server shutdown" | Out-File $outputPath -Append - $exitCode = 1 - } - } - } if ($clientAccount) { try { diff --git a/crates/agent-policy-tester/src/windows.rs b/crates/agent-policy-tester/src/windows.rs index bc5f51cb4..aea53e539 100644 --- a/crates/agent-policy-tester/src/windows.rs +++ b/crates/agent-policy-tester/src/windows.rs @@ -918,6 +918,13 @@ async fn standard_user_management(ready_path: &Path, nonce: &str, client: &Proce let management = policy_management_by_pipe(pipe_name).await?; ensure!(management["State"] == "Missing", "expected a missing policy"); + let missing_policy = request(pipe_name, "GET", "/v1/policy").await?; + ensure!( + missing_policy.status == 404 + && missing_policy.json()?["Code"] == "NotFound" + && missing_policy.json()?["Message"] == "active policy is unavailable", + "standard-user missing policy read did not return the canonical not-found response" + ); let valid_draft = policy_draft("tests.standard-user", "Test"); let validation = validate_policy_by_pipe(pipe_name, &valid_draft).await?; @@ -1107,7 +1114,7 @@ async fn managed_policy_lifecycle(agent_path: &Path) -> anyhow::Result<()> { &agent, "Update", "Reject", - stale_token, + stale_token.clone(), policy_draft("tests.managed-external", "Stale"), ) .await?; @@ -1120,6 +1127,19 @@ async fn managed_policy_lifecycle(agent_path: &Path) -> anyhow::Result<()> { ); wait_for_log(&agent, "stale_conflict").await?; + let stale_confirm = replace_policy_response( + &agent, + "Update", + "ConfirmOverwrite", + stale_token, + policy_draft("tests.managed-external", "Stale confirmed overwrite"), + ) + .await?; + ensure!( + stale_confirm.status == 409 && stale_confirm.json()?["Code"] == "StalePolicyStoreToken", + "stale ConfirmOverwrite did not return a store token conflict" + ); + let current_token = stale["Management"]["StoreToken"].clone(); let confirmed = replace_policy_response( &agent, From a9395f50e9d6f32105e71b635f81e1bcb389462d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Sun, 20 Sep 2026 17:50:53 +0900 Subject: [PATCH 14/39] fix(agent): accept advisory policy findings Remove retired warning acknowledgment requests while retaining receipt and conflict enforcement in policy management E2E coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/agent-policy-tester/src/windows.rs | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/crates/agent-policy-tester/src/windows.rs b/crates/agent-policy-tester/src/windows.rs index aea53e539..c7511892d 100644 --- a/crates/agent-policy-tester/src/windows.rs +++ b/crates/agent-policy-tester/src/windows.rs @@ -756,7 +756,6 @@ async fn replace_policy_response_by_pipe( "ExpectedStoreToken": expected_store_token, "Operation": operation, "ConflictHandling": conflict_handling, - "WarningsAcknowledged": true, "Draft": validation["CanonicalDraft"], "ValidationReceipt": validation["ValidationReceipt"] }); @@ -1210,16 +1209,9 @@ async fn warnings_identity_and_receipts( "ExpectedStoreToken": current["Management"]["StoreToken"], "Operation": "ReplaceIdentity", "ConflictHandling": "Reject", - "WarningsAcknowledged": false, "Draft": validation["CanonicalDraft"], "ValidationReceipt": validation["ValidationReceipt"] }); - let warning = send_replacement(&agent.pipe_name, &replacement).await?; - ensure!( - warning.status == 409 && warning.json()?["Code"] == "WarningConfirmationRequired", - "unacknowledged warnings were not rejected" - ); - replacement["WarningsAcknowledged"] = json!(true); replacement["Draft"]["Metadata"]["Publisher"] = json!("Tampered after validation"); let tampered = send_replacement(&agent.pipe_name, &replacement).await?; ensure!( @@ -1245,12 +1237,11 @@ async fn warnings_identity_and_receipts( replaced["Policy"]["Metadata"]["Id"] == "tests.replaced-identity" && replaced["Policy"]["Metadata"]["Revision"] == 1 && replaced["Policy"]["PolicyFormatVersion"] == "1.7.3", - "ReplaceIdentity did not preserve the canonical contract and reset revision" + "advisory findings did not permit the canonical identity replacement" ); wait_for_log(agent, "Policy change succeeded").await?; wait_for_log(agent, "replace_identity").await?; wait_for_log(agent, "invalid_receipt").await?; - wait_for_log(agent, "warnings_not_acknowledged").await?; Ok(replaced) } From aaf0bcf9dc248cc78af21b372df2a774bc2f75cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 9 Sep 2026 01:22:26 -0400 Subject: [PATCH 15/39] feat(agent,agent-installer): add policy consent helper Install one protected NativeAOT helper that authenticates retained UniGetUI and Agent process identities before forwarding bounded policy replacement requests. Preserve protocol 2.0 conflict and uncertainty semantics while integrating transactional discovery, signing, and packaging. Issue: #1963 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 31 + .github/workflows/package.yml | 7 +- ci/package-agent-windows.ps1 | 10 +- crates/now-package-broker/src/auth.rs | 83 +++ crates/now-package-broker/src/server/mod.rs | 7 +- ...DevolutionsAgentPolicyConsent.Tests.csproj | 22 + .../AgentPolicyConsent.Tests/ProtocolTests.cs | 445 ++++++++++++ package/AgentPolicyConsent/BrokerClient.cs | 250 +++++++ .../DevolutionsAgentPolicyConsent.csproj | 23 + package/AgentPolicyConsent/PeerTrust.cs | 682 ++++++++++++++++++ .../PolicyConsentContract.cs | 13 + package/AgentPolicyConsent/Program.cs | 173 +++++ package/AgentPolicyConsent/Protocol.cs | 240 ++++++ package/AgentPolicyConsent/app.manifest | 11 + .../PolicyConsentDiscoveryTests.cs | 45 ++ .../DevolutionsAgent.csproj | 1 + package/AgentWindowsManaged/Program.cs | 55 ++ .../AgentWindowsManaged/Resources/Includes.cs | 12 + 18 files changed, 2106 insertions(+), 4 deletions(-) create mode 100644 package/AgentPolicyConsent.Tests/DevolutionsAgentPolicyConsent.Tests.csproj create mode 100644 package/AgentPolicyConsent.Tests/ProtocolTests.cs create mode 100644 package/AgentPolicyConsent/BrokerClient.cs create mode 100644 package/AgentPolicyConsent/DevolutionsAgentPolicyConsent.csproj create mode 100644 package/AgentPolicyConsent/PeerTrust.cs create mode 100644 package/AgentPolicyConsent/PolicyConsentContract.cs create mode 100644 package/AgentPolicyConsent/Program.cs create mode 100644 package/AgentPolicyConsent/Protocol.cs create mode 100644 package/AgentPolicyConsent/app.manifest create mode 100644 package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d8526760..c701b7d94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -926,6 +926,9 @@ jobs: $DAgentSessionExecutable = Join-Path $TargetOutputPath "DevolutionsSession.exe" echo "dagent-session-executable=$DAgentSessionExecutable" >> $Env:GITHUB_OUTPUT + $DAgentPolicyConsentHelper = Join-Path $TargetOutputPath "DevolutionsAgentPolicyConsent.exe" + echo "dagent-policy-consent-helper=$DAgentPolicyConsentHelper" >> $Env:GITHUB_OUTPUT + $DAgentUpdaterExecutable = Join-Path $TargetOutputPath "DevolutionsAgentUpdater.exe" echo "dagent-updater-executable=$DAgentUpdaterExecutable" >> $Env:GITHUB_OUTPUT } @@ -1091,6 +1094,28 @@ jobs: DAGENT_EXECUTABLE: ${{ steps.load-variables.outputs.dagent-executable }} TARGET_OUTPUT_PATH: ${{ steps.load-variables.outputs.target-output-path }} + - name: Build NativeAOT policy consent helper + if: ${{ matrix.os == 'windows' }} + run: | + $Rid = "win-${{ matrix.arch }}" + $Output = Split-Path -Parent '${{ steps.load-variables.outputs.dagent-policy-consent-helper }}' + dotnet publish package/AgentPolicyConsent/DevolutionsAgentPolicyConsent.csproj ` + --configuration Release ` + --runtime $Rid ` + --output $Output ` + -p:Version=${{ needs.preflight.outputs.version }} + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + $Helper = '${{ steps.load-variables.outputs.dagent-policy-consent-helper }}' + if (-Not (Test-Path -LiteralPath $Helper -PathType Leaf)) { + throw "NativeAOT policy consent helper was not produced" + } + if ((Get-Item -LiteralPath $Helper).Length -gt 8MB) { + throw "NativeAOT policy consent helper exceeds 8 MiB" + } + shell: pwsh + - name: Package if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} run: | @@ -1104,6 +1129,7 @@ jobs: $Env:DAGENT_PEDM_SHELL_EXT_DLL = "${{ steps.load-variables.outputs.dagent-pedm-shell-ext-dll }}" $Env:DAGENT_PEDM_SHELL_EXT_MSIX = "${{ steps.load-variables.outputs.dagent-pedm-shell-ext-msix }}" $Env:DAGENT_SESSION_EXECUTABLE = "${{ steps.load-variables.outputs.dagent-session-executable }}" + $Env:DAGENT_POLICY_CONSENT_HELPER = "${{ steps.load-variables.outputs.dagent-policy-consent-helper }}" $Env:DAGENT_TUN2SOCKS_EXE = "${{ steps.tun2socks.outputs.tun2socks-executable-path }}" $Env:DAGENT_WINTUN_DLL = "${{ steps.tun2socks.outputs.wintun-library-path }}" $Env:DAGENT_MULTI_PWSH_EXECUTABLE = "${{ steps.multi-pwsh.outputs.executable-path }}" @@ -1225,6 +1251,11 @@ jobs: run: dotnet test package/AgentWindowsManaged.Tests/DevolutionsAgent.Installer.Tests.csproj shell: pwsh + - name: Policy consent helper tests + run: dotnet test package/AgentPolicyConsent.Tests/DevolutionsAgentPolicyConsent.Tests.csproj -c Release + shell: pwsh + + winapi-sanitizer-tests: name: Windows API sanitizer tests runs-on: windows-2022 diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 27ee014f9..af56e0b9f 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -322,7 +322,7 @@ jobs: run: | $IncludePattern = @(switch ('${{ matrix.project }}') { 'devolutions-gateway' { @('DevolutionsGateway.exe') } - 'devolutions-agent' { @('DevolutionsAgent.exe', 'DevolutionsAgentUpdater.exe', 'DevolutionsPedmShellExt.dll', 'DevolutionsPedmShellExt.msix', 'DevolutionsDesktopAgent.exe') } + 'devolutions-agent' { @('DevolutionsAgent.exe', 'DevolutionsAgentUpdater.exe', 'DevolutionsAgentPolicyConsent.exe', 'DevolutionsPedmShellExt.dll', 'DevolutionsPedmShellExt.msix', 'DevolutionsDesktopAgent.exe') } 'jetsocat' { @('jetsocat.exe', 'jetsocat') } }) $ExcludePattern = "*.pdb" @@ -495,6 +495,7 @@ jobs: $Env:DAGENT_PEDM_SHELL_EXT_DLL = Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsPedmShellExt.dll' -File | Select-Object -First 1 $Env:DAGENT_PEDM_SHELL_EXT_MSIX = Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsPedmShellExt.msix' -File | Select-Object -First 1 $Env:DAGENT_SESSION_EXECUTABLE = Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsSession.exe' -File | Select-Object -First 1 + $Env:DAGENT_POLICY_CONSENT_HELPER = Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsAgentPolicyConsent.exe' -File | Select-Object -First 1 $Env:DAGENT_TUN2SOCKS_EXE = Join-Path $ArchRoot 'tun2socks.exe' $Env:DAGENT_WINTUN_DLL = Join-Path $ArchRoot 'wintun.dll' $MultiPwshDirectory = Join-Path $Env:RUNNER_TEMP 'multi-pwsh' 'windows' $Arch @@ -508,6 +509,7 @@ jobs: Write-Host "DAGENT_PEDM_SHELL_EXT_DLL = ${Env:DAGENT_PEDM_SHELL_EXT_DLL}" Write-Host "DAGENT_PEDM_SHELL_EXT_MSIX = ${Env:DAGENT_PEDM_SHELL_EXT_MSIX}" Write-Host "DAGENT_SESSION_EXECUTABLE = ${Env:DAGENT_SESSION_EXECUTABLE}" + Write-Host "DAGENT_POLICY_CONSENT_HELPER = ${Env:DAGENT_POLICY_CONSENT_HELPER}" Write-Host "DAGENT_TUN2SOCKS_EXE = ${Env:DAGENT_TUN2SOCKS_EXE}" Write-Host "DAGENT_WINTUN_DLL = ${Env:DAGENT_WINTUN_DLL}" Write-Host "DAGENT_MULTI_PWSH_EXECUTABLE = ${Env:DAGENT_MULTI_PWSH_EXECUTABLE}" @@ -534,7 +536,8 @@ jobs: @((Join-Path $ArchRoot DesktopAgent), (Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsPedmShellExt.dll' | Select-Object -First 1), (Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsPedmShellExt.msix' | Select-Object -First 1), - (Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsSession.exe' | Select-Object -First 1)) | ForEach-Object { + (Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsSession.exe' | Select-Object -First 1), + (Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsAgentPolicyConsent.exe' | Select-Object -First 1)) | ForEach-Object { Remove-Item $_ -Recurse -ErrorAction SilentlyContinue | Out-Null } } diff --git a/ci/package-agent-windows.ps1 b/ci/package-agent-windows.ps1 index 4a37cc512..10f0e7219 100644 --- a/ci/package-agent-windows.ps1 +++ b/ci/package-agent-windows.ps1 @@ -11,6 +11,8 @@ param( [parameter(Mandatory = $true)] [string] $SessionExe, [parameter(Mandatory = $true)] + [string] $PolicyConsentHelper, + [parameter(Mandatory = $true)] [ValidateSet('x64', 'arm64')] [string] $Architecture, [string] $Outfile @@ -98,6 +100,9 @@ function New-AgentMsi() { # The path to the devolutions-session.exe file. [string] $SessionExe, [parameter(Mandatory = $true)] + # The path to the DevolutionsAgentPolicyConsent.exe file. + [string] $PolicyConsentHelper, + [parameter(Mandatory = $true)] [ValidateSet('x64', 'arm64')] # Architecture: x64 or arm64 [string] $Architecture, @@ -120,6 +125,7 @@ function New-AgentMsi() { $PedmDll = Convert-Path -Path $PedmDll $PedmMsix = Convert-Path -Path $PedmMsix $SessionExe = Convert-Path -Path $SessionExe + $PolicyConsentHelper = Convert-Path -Path $PolicyConsentHelper if ($Outfile) { $Outfile = Convert-Path -Path $Outfile } @@ -137,6 +143,7 @@ function New-AgentMsi() { $myUpdaterExe = Set-FileNameAndCopy -Path $UpdaterExe -NewName 'DevolutionsAgentUpdater.exe' # The session is a service that gets launched on demand. $mySessionExe = Set-FileNameAndCopy -Path $SessionExe -NewName 'DevolutionsSession.exe' + $myPolicyConsentHelper = Set-FileNameAndCopy -Path $PolicyConsentHelper -NewName 'DevolutionsAgentPolicyConsent.exe' Write-Output "$repoDir\dotnet\DesktopAgent\bin\Release\net48\DevolutionsDesktopAgent.exe" @@ -145,6 +152,7 @@ function New-AgentMsi() { Set-EnvVarPath 'DAGENT_PEDM_SHELL_EXT_DLL' $myPedmDll Set-EnvVarPath 'DAGENT_PEDM_SHELL_EXT_MSIX' $myPedmMsix Set-EnvVarPath 'DAGENT_SESSION_EXECUTABLE' $mySessionExe + Set-EnvVarPath 'DAGENT_POLICY_CONSENT_HELPER' $myPolicyConsentHelper # The actual DevolutionsDesktopAgent.exe will be `\dotnet\DesktopAgent\bin\Release\net48\DevolutionsDesktopAgent.exe`. # After install, the contents of `net48` will be copied to `C:\Program Files\Devolutions\Agent\desktop\`. @@ -184,4 +192,4 @@ function New-AgentMsi() { Pop-Location } -New-AgentMsi -Generate:($Generate.IsPresent) -Exe $Exe -UpdaterExe $UpdaterExe -PedmDll $PedmDll -PedmMsix $PedmMsix -SessionExe $SessionExe -Architecture $Architecture -Outfile $Outfile +New-AgentMsi -Generate:($Generate.IsPresent) -Exe $Exe -UpdaterExe $UpdaterExe -PedmDll $PedmDll -PedmMsix $PedmMsix -SessionExe $SessionExe -PolicyConsentHelper $PolicyConsentHelper -Architecture $Architecture -Outfile $Outfile diff --git a/crates/now-package-broker/src/auth.rs b/crates/now-package-broker/src/auth.rs index d647e750c..f293a0306 100644 --- a/crates/now-package-broker/src/auth.rs +++ b/crates/now-package-broker/src/auth.rs @@ -32,6 +32,7 @@ use windows::Win32::System::Threading::{ use crate::policy_security::RetainedExecutableSecurity; const PROCESS_SYNCHRONIZE: PROCESS_ACCESS_RIGHTS = PROCESS_ACCESS_RIGHTS(0x0010_0000); +const POLICY_CONSENT_HELPER_NAME: &str = "DevolutionsAgentPolicyConsent.exe"; const PROCESS_IDENTITY_ACCESS: PROCESS_ACCESS_RIGHTS = PROCESS_ACCESS_RIGHTS( PROCESS_QUERY_INFORMATION.0 | PROCESS_QUERY_LIMITED_INFORMATION.0 | PROCESS_VM_READ.0 | PROCESS_SYNCHRONIZE.0, ); @@ -289,6 +290,20 @@ impl PipeClient { Ok(()) } + pub(crate) fn validate_policy_write(&self, skip_signature_validation: bool) -> anyhow::Result<()> { + self.validate_connection(skip_signature_validation)?; + // Dev builds cannot enforce helper identity when signature validation is explicitly disabled. + if signature_validation_skipped(skip_signature_validation) { + return Ok(()); + } + let agent = std::env::current_exe().context("failed to query Agent executable path")?; + let executable_file = self + .executable_file + .as_deref() + .context("policy consent helper executable handle is not retained")?; + Self::validate_policy_consent_helper_path(&self.executable_path, executable_file, &agent) + } + fn validate_process_instance(&self) -> anyhow::Result<()> { let Some(process) = &self.process else { return Ok(()); @@ -304,6 +319,29 @@ impl PipeClient { ) } + fn validate_policy_consent_helper_path(client: &Path, client_file: &File, agent: &Path) -> anyhow::Result<()> { + if !client + .file_name() + .is_some_and(|name| name.eq_ignore_ascii_case(POLICY_CONSENT_HELPER_NAME)) + { + bail!("policy replacement requires the Agent policy consent helper"); + } + let expected = agent + .parent() + .context("Agent executable has no installation directory")? + .join(POLICY_CONSENT_HELPER_NAME); + if !crate::policy_security::windows_paths_equal(client, &expected) { + bail!("policy consent helper is not the installed Agent helper path"); + } + let expected_id = file_id(&expected).context("failed to query installed policy consent helper identity")?; + let retained_id = + file_id_from_handle(client_file).context("failed to query retained policy consent helper identity")?; + if !same_file(&expected_id, &retained_id) { + bail!("policy consent helper does not match the installed helper"); + } + Ok(()) + } + /// Validate that the request's `effective_user` denotes the authenticated pipe client user. /// /// The name is resolved to a SID and compared against the SID captured at connect, @@ -539,6 +577,51 @@ mod tests { .expect_err("a recycled PID with a different creation time must be rejected"); } + #[test] + fn policy_consent_helper_requires_exact_agent_sibling_path() { + let current_executable = std::env::current_exe().expect("current executable"); + let current_file = open_executable_file(¤t_executable).expect("open current executable"); + let agent = Path::new(r"C:\Program Files\Devolutions\Agent\DevolutionsAgent.exe"); + assert!( + PipeClient::validate_policy_consent_helper_path( + Path::new(r"C:\Program Files\Devolutions\Agent\DevolutionsAgentPolicyConsent.exe"), + ¤t_file, + agent, + ) + .is_err(), + "path text alone must not authorize a different retained image" + ); + assert!( + PipeClient::validate_policy_consent_helper_path( + Path::new(r"C:\Users\Alice\DevolutionsAgentPolicyConsent.exe"), + ¤t_file, + agent, + ) + .is_err() + ); + assert!( + PipeClient::validate_policy_consent_helper_path( + Path::new(r"C:\Users\Alice\UniGetUI.exe"), + ¤t_file, + agent, + ) + .is_err() + ); + } + + #[test] + fn policy_consent_helper_accepts_exact_retained_sibling() { + let temp = tempfile::tempdir().expect("temp directory"); + let agent = temp.path().join("DevolutionsAgent.exe"); + let helper = temp.path().join(POLICY_CONSENT_HELPER_NAME); + std::fs::write(&agent, b"agent path anchor").expect("write Agent path anchor"); + std::fs::copy(std::env::current_exe().expect("current executable"), &helper).expect("copy helper fixture"); + let retained = open_executable_file(&helper).expect("retain helper fixture"); + + PipeClient::validate_policy_consent_helper_path(&helper, &retained, &agent) + .expect("exact retained sibling must be accepted"); + } + #[test] fn exited_process_cannot_supply_executable_identity() { let mut child = std::process::Command::new("powershell.exe") diff --git a/crates/now-package-broker/src/server/mod.rs b/crates/now-package-broker/src/server/mod.rs index 6ace82de8..77f835d87 100644 --- a/crates/now-package-broker/src/server/mod.rs +++ b/crates/now-package-broker/src/server/mod.rs @@ -307,7 +307,12 @@ async fn authenticate_policy_management( | (&Method::PUT, "/v1/policy") ); if protected { - if let Err(error) = client.validate_connection(state.skip_signature_validation) { + let authentication = if matches!((request.method(), request.uri().path()), (&Method::PUT, "/v1/policy")) { + client.validate_policy_write(state.skip_signature_validation) + } else { + client.validate_connection(state.skip_signature_validation) + }; + if let Err(error) = authentication { if let Some(audit) = write_audit { audit.denied(crate::audit::DenialReason::AuthenticationFailed); } diff --git a/package/AgentPolicyConsent.Tests/DevolutionsAgentPolicyConsent.Tests.csproj b/package/AgentPolicyConsent.Tests/DevolutionsAgentPolicyConsent.Tests.csproj new file mode 100644 index 000000000..24bdd8852 --- /dev/null +++ b/package/AgentPolicyConsent.Tests/DevolutionsAgentPolicyConsent.Tests.csproj @@ -0,0 +1,22 @@ + + + net10.0-windows + win-x64 + true + false + enable + enable + + + + + + all + + + + + + diff --git a/package/AgentPolicyConsent.Tests/ProtocolTests.cs b/package/AgentPolicyConsent.Tests/ProtocolTests.cs new file mode 100644 index 000000000..36456f288 --- /dev/null +++ b/package/AgentPolicyConsent.Tests/ProtocolTests.cs @@ -0,0 +1,445 @@ +using System.Buffers.Binary; +using System.Text.Json; +using DevolutionsAgentPolicyConsent; +using Microsoft.Win32.SafeHandles; +using Xunit; + +namespace DevolutionsAgentPolicyConsent.Tests; + +public sealed class ProtocolTests +{ + private const string RequestId = "0123456789abcdef0123456789abcdef"; + + [Fact] + public void ArgumentsRequireExactBoundIdentity() + { + Arguments parsed = Protocol.ParseArguments( + [ + "--protocol", "2.0", + "--pipe", $"UniGetUI.PolicyElevation.{RequestId}", + "--parent-pid", "42", + "--parent-created", "638900000000000000", + "--session", "1", + ]); + + Assert.Equal(42, parsed.ParentProcessId); + Assert.Equal((uint)1, parsed.SessionId); + } + + public sealed class TrustPolicyTests + { + [Fact] + public void CurrentAndTransitionSignersAreAccepted() + { + Assert.True(PeerLease.IsAllowedSigner(PeerLease.CurrentUiSignerSpkiSha256)); + Assert.True(PeerLease.IsAllowedSigner(PeerLease.TransitionUiSignerSpkiSha256)); + } + + [Fact] + public void LookalikeSignerIsRejected() + { + Assert.False(PeerLease.IsAllowedSigner(new string('0', 64))); + } + + [Fact] + public void SignerMatchingIsCaseSensitive() + { + Assert.False(PeerLease.IsAllowedSigner(PeerLease.CurrentUiSignerSpkiSha256.ToUpperInvariant())); + } + + [Theory] + [InlineData("3.3.7")] + [InlineData("2026.2.7")] + public void ProductBindingSupportsProtocolEraAndCurrentInstallModes(string version) + { + Assert.True(PeerLease.IsSupportedUiIdentity("UniGetUI", "UniGetUI.dll", version)); + } + + [Theory] + [InlineData("Lookalike", "UniGetUI.dll", "2026.2.7")] + [InlineData("UniGetUI", "malware.exe", "2026.2.7")] + [InlineData("UniGetUI", "UniGetUI.dll", "3.3.6")] + public void ProductBindingRejectsLookalikes(string product, string originalFilename, string version) + { + Assert.False(PeerLease.IsSupportedUiIdentity(product, originalFilename, version)); + } + + [Theory] + [InlineData(41, 638900000000000000, 1)] + [InlineData(42, 638900000000000001, 1)] + [InlineData(42, 638900000000000000, 2)] + public void ProcessIdentityRejectsPidReuseAndSessionMismatch(int pid, long created, uint session) + { + Arguments expected = new("pipe", 42, 638900000000000000, 1); + Assert.False(PeerLease.MatchesProcessIdentity(expected, pid, created, session)); + } + + [Fact] + public void ProcessIdentityAcceptsExactRetainedInstance() + { + Arguments expected = new("pipe", 42, 638900000000000000, 1); + Assert.True(PeerLease.MatchesProcessIdentity(expected, 42, 638900000000000000, 1)); + } + + [Fact] + public void BrokerServerRequiresExactAgentSiblingPath() + { + Assert.True(BrokerServerLease.IsExpectedPath( + @"C:\Program Files\Devolutions\Agent\DevolutionsAgent.exe", + @"c:\PROGRAM FILES\Devolutions\Agent\DevolutionsAgent.exe")); + Assert.False(BrokerServerLease.IsExpectedPath( + @"C:\Users\Alice\DevolutionsAgent.exe", + @"C:\Program Files\Devolutions\Agent\DevolutionsAgent.exe")); + } + + [Fact] + public void AuthenticodeSignerComesFromRetainedImageHandle() + { + string path = Path.Combine(AppContext.BaseDirectory, "testhost.exe"); + using SafeFileHandle image = OpenImage(path); + using var certificate = PeerLease.VerifyAuthenticodeSigner(path, image, "test host"); + Assert.NotEmpty(certificate.RawData); + } + + [Fact] + public void ProcessImageMappingAcceptsOnlyCurrentMappedImage() + { + using SafeProcessHandle process = Native.OpenProcess( + PeerLease.ProcessQueryInformation | + PeerLease.ProcessQueryLimitedInformation | + PeerLease.Synchronize, + false, + Environment.ProcessId); + Assert.False(process.IsInvalid); + + string processPath = PeerLease.ImagePath(process); + using SafeFileHandle processImage = OpenImage(processPath); + PeerLease.VerifyImageMapping(process, processImage); + + using SafeFileHandle differentImage = + OpenImage(Path.Combine(AppContext.BaseDirectory, "DevolutionsAgentPolicyConsent.exe")); + Assert.Throws(() => PeerLease.VerifyImageMapping(process, differentImage)); + } + + [Fact] + public void BrokerServerRejectsNonSystemProcessToken() + { + using SafeProcessHandle process = Native.OpenProcess( + PeerLease.ProcessQueryLimitedInformation, + false, + Environment.ProcessId); + Assert.False(process.IsInvalid); + Assert.False(PeerLease.IsLocalSystemProcess(process)); + } + + [Fact] + public void FileIdentityRejectsDifferentImage() + { + using SafeFileHandle first = OpenImage(Path.Combine(AppContext.BaseDirectory, "testhost.exe")); + using SafeFileHandle same = OpenImage(Path.Combine(AppContext.BaseDirectory, "testhost.exe")); + using SafeFileHandle different = + OpenImage(Path.Combine(AppContext.BaseDirectory, "DevolutionsAgentPolicyConsent.exe")); + + Assert.True(PeerLease.SameFile(first, same)); + Assert.False(PeerLease.SameFile(first, different)); + } + + private static SafeFileHandle OpenImage(string path) + { + SafeFileHandle image = Native.CreateFile( + path, + PeerLease.GenericRead | PeerLease.FileExecute | PeerLease.Synchronize, + PeerLease.FileShareRead, + IntPtr.Zero, + PeerLease.OpenExisting, + 0, + IntPtr.Zero); + Assert.False(image.IsInvalid); + return image; + } + } + + [Theory] + [InlineData("--extra")] + [InlineData("--pipe")] + public void ArgumentsRejectUnknownOrDuplicateNames(string name) + { + string[] args = + [ + "--protocol", "2.0", + "--pipe", $"UniGetUI.PolicyElevation.{RequestId}", + "--parent-pid", "42", + "--parent-created", "638900000000000000", + name, "1", + ]; + + Assert.Throws(() => Protocol.ParseArguments(args)); + } + + [Fact] + public async Task FrameUsesBigEndianLengthAndRoundTrips() + { + byte[] body = [1, 2, 3, 4]; + using MemoryStream stream = new(); + await Protocol.WriteFrameAsync(stream, body, body.Length, CancellationToken.None); + Assert.Equal((uint)body.Length, BinaryPrimitives.ReadUInt32BigEndian(stream.GetBuffer())); + stream.Position = 0; + Assert.Equal(body, await Protocol.ReadFrameAsync(stream, body.Length, CancellationToken.None)); + } + + [Fact] + public async Task OversizedFrameIsRejectedBeforeBodyRead() + { + byte[] header = new byte[4]; + BinaryPrimitives.WriteUInt32BigEndian(header, 128); + using MemoryStream stream = new(header); + await Assert.ThrowsAsync( + () => Protocol.ReadFrameAsync(stream, 127, CancellationToken.None)); + } + + [Fact] + public async Task ZeroLengthAndTruncatedFramesAreRejected() + { + using MemoryStream empty = new(new byte[4]); + await Assert.ThrowsAsync( + () => Protocol.ReadFrameAsync(empty, 128, CancellationToken.None)); + + using MemoryStream truncated = new([0, 0, 0, 2, 1]); + await Assert.ThrowsAsync( + () => Protocol.ReadFrameAsync(truncated, 128, CancellationToken.None)); + } + + [Fact] + public async Task FrameReadHonorsCancellation() + { + using CancellationTokenSource cancellation = new(TimeSpan.FromMilliseconds(25)); + await Assert.ThrowsAnyAsync( + () => Protocol.ReadFrameAsync(new BlockingStream(), 128, cancellation.Token)); + } + + [Fact] + public void RequestRejectsUnknownJsonMembers() + { + string json = + $$"""{"protocolVersion":"2.0","requestId":"{{RequestId}}","operation":"Update","conflictHandling":"Reject","expectedStoreToken":"a","validationReceipt":"b","warningsAcknowledged":false,"draft":{},"command":"cmd.exe"}"""; + + Assert.Throws( + () => JsonSerializer.Deserialize(json, ProtocolJsonContext.Default.ElevationRequest)); + } + + [Fact] + public void OfficialRequestContainsOnlyPolicyReplacementFields() + { + using JsonDocument draft = JsonDocument.Parse("""{"Metadata":{"Id":"tests.policy"}}"""); + ElevationRequest request = new( + "2.0", + RequestId, + "Update", + "ConfirmOverwrite", + "token", + "receipt", + true, + draft.RootElement.Clone()); + + using JsonDocument official = JsonDocument.Parse(BrokerClient.CreateOfficialRequest(request)); + string[] names = official.RootElement.EnumerateObject().Select(property => property.Name).ToArray(); + Assert.Equal( + [ + "RequestKind", + "RequestVersion", + "ExpectedStoreToken", + "Operation", + "ConflictHandling", + "WarningsAcknowledged", + "Draft", + "ValidationReceipt", + ], names); + } + + [Fact] + public void RequestRequiresEveryMemberAndObjectDraft() + { + string missingAcknowledgement = + $$$"""{"protocolVersion":"2.0","requestId":"{{{RequestId}}}","operation":"Update","conflictHandling":"Reject","expectedStoreToken":"a","validationReceipt":"b","draft":{}}"""; + Assert.Throws( + () => JsonSerializer.Deserialize(missingAcknowledgement, ProtocolJsonContext.Default.ElevationRequest)); + + using JsonDocument draft = JsonDocument.Parse("[]"); + ElevationRequest request = new("2.0", RequestId, "Update", "Reject", "a", "b", false, draft.RootElement); + Assert.Throws(() => Protocol.ValidateRequest(request)); + } + + [Theory] + [InlineData("Delete", "Reject")] + [InlineData("Update", "Overwrite")] + public void RequestRejectsUnknownOperations(string operation, string conflictHandling) + { + using JsonDocument draft = JsonDocument.Parse("{}"); + ElevationRequest request = new( + "2.0", + RequestId, + operation, + conflictHandling, + "a", + "b", + false, + draft.RootElement); + + Assert.Throws(() => Protocol.ValidateRequest(request)); + } + + [Fact] + public void CommittedResponseContainsOnlyStoreToken() + { + ElevationResponse response = new( + "2.0", + RequestId, + "Committed", + null, + null, + "new-token", + null, + null, + null); + + Protocol.ValidateResponse(response); + string json = JsonSerializer.Serialize(response, ProtocolJsonContext.Default.ElevationResponse); + Assert.DoesNotContain("payload", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("message", json, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("Active", "policy.id")] + [InlineData("Missing", null)] + [InlineData("Invalid", null)] + public void StaleRejectionCarriesBoundedConflictContext(string state, string? policyId) + { + ElevationResponse response = new( + "2.0", + RequestId, + "Rejected", + 409, + "StalePolicyStoreToken", + null, + "current-token", + state, + policyId); + + Protocol.ValidateResponse(response); + } + + [Fact] + public void UnknownResponseCannotClaimCommitOrConflict() + { + ElevationResponse response = new( + "2.0", + RequestId, + "Unknown", + null, + "Timeout", + "claimed-token", + null, + null, + null); + + Assert.Throws(() => Protocol.ValidateResponse(response)); + } + + [Fact] + public void ResponseRequiresExplicitNullableMembers() + { + string missingConflictFields = + $$$"""{"protocolVersion":"2.0","requestId":"{{{RequestId}}}","disposition":"Unknown","brokerStatusCode":null,"brokerErrorCode":"Timeout","committedStoreToken":null}"""; + + Assert.Throws( + () => JsonSerializer.Deserialize(missingConflictFields, ProtocolJsonContext.Default.ElevationResponse)); + } + + [Fact] + public void MaximumStaleResponseFitsExactWireBudget() + { + ElevationResponse response = new( + "2.0", + RequestId, + "Rejected", + 409, + "StalePolicyStoreToken", + null, + "T" + new string('"', 511), + "Active", + "P" + new string('"', 2047)); + + Protocol.ValidateResponse(response); + byte[] body = JsonSerializer.SerializeToUtf8Bytes( + response, + ProtocolJsonContext.Default.ElevationResponse); + Assert.InRange(body.Length, 1, Protocol.MaxResponseBodyBytes); + } + + [Fact] + public void BrokerSuccessMapsToCompactCommittedAcknowledgement() + { + ElevationResponse response = BrokerClient.ParseResponse( + RequestId, + HttpResponse( + 200, + """{"ResponseKind":"PolicyReplacementResponse","ResponseVersion":"1.0","Management":{"StoreToken":"new-token"}}""")); + + Assert.Equal("Committed", response.Disposition); + Assert.Equal("new-token", response.CommittedStoreToken); + Assert.Null(response.BrokerStatusCode); + } + + [Fact] + public void BrokerStaleErrorMapsExactConflictContext() + { + ElevationResponse response = BrokerClient.ParseResponse( + RequestId, + HttpResponse( + 409, + """{"ResponseKind":"ErrorResponse","ResponseVersion":"1.0","Code":"StalePolicyStoreToken","Management":{"StoreToken":"current-token","State":"Active","Policy":{"Metadata":{"Id":"policy.id"}}}}""")); + + Assert.Equal("Rejected", response.Disposition); + Assert.Equal(409, response.BrokerStatusCode); + Assert.Equal("current-token", response.ConflictStoreToken); + Assert.Equal("Active", response.ConflictState); + Assert.Equal("policy.id", response.ConflictPolicyId); + } + + [Fact] + public void EmptyBrokerResponseMapsToUnknown() + { + ElevationResponse response = BrokerClient.ParseResponse( + RequestId, + HttpResponse(503, string.Empty)); + + Assert.Equal("Unknown", response.Disposition); + Assert.Equal(503, response.BrokerStatusCode); + Assert.Equal("EmptyResponse", response.BrokerErrorCode); + } + + private static byte[] HttpResponse(int status, string body) => + System.Text.Encoding.UTF8.GetBytes( + $"HTTP/1.1 {status} Test\r\nContent-Length: {System.Text.Encoding.UTF8.GetByteCount(body)}\r\n\r\n{body}"); + + private sealed class BlockingStream : Stream + { + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override void Flush() => throw new NotSupportedException(); + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override async ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default) + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return 0; + } + } +} diff --git a/package/AgentPolicyConsent/BrokerClient.cs b/package/AgentPolicyConsent/BrokerClient.cs new file mode 100644 index 000000000..bdcbd5120 --- /dev/null +++ b/package/AgentPolicyConsent/BrokerClient.cs @@ -0,0 +1,250 @@ +using System.Buffers; +using System.IO.Pipes; +using System.Text; +using System.Text.Json; + +namespace DevolutionsAgentPolicyConsent; + +internal static class BrokerClient +{ + private const string PipeName = "Devolutions.Now.PackageBroker.v1"; + private const int MaximumHeaderBytes = 64 * 1024; + private const int MaximumBrokerResponseBytes = 50_606_928; + private static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(5); + + internal static async Task ReplaceAsync( + ElevationRequest request, + CancellationToken cancellationToken) + { + byte[] body = CreateOfficialRequest(request); + try + { + using NamedPipeClientStream pipe = new( + ".", + PipeName, + PipeDirection.InOut, + PipeOptions.Asynchronous | PipeOptions.WriteThrough, + System.Security.Principal.TokenImpersonationLevel.Anonymous); + + using CancellationTokenSource connectTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + connectTimeout.CancelAfter(ConnectTimeout); + await pipe.ConnectAsync(connectTimeout.Token); + using BrokerServerLease broker = BrokerServerLease.Open(pipe.SafePipeHandle); + + byte[] headers = Encoding.ASCII.GetBytes( + $"PUT /v1/policy HTTP/1.1\r\nHost: now-package-broker\r\nConnection: close\r\n" + + $"Content-Type: application/json\r\nAccept: application/json\r\nContent-Length: {body.Length}\r\n\r\n"); + await pipe.WriteAsync(headers, cancellationToken); + await pipe.WriteAsync(body, cancellationToken); + await pipe.FlushAsync(cancellationToken); + + byte[] response = await ReadBoundedAsync( + pipe, + MaximumBrokerResponseBytes + MaximumHeaderBytes, + cancellationToken); + return ParseResponse(request.RequestId, response); + } + catch (OperationCanceledException) + { + return Unknown(request.RequestId, null, "Timeout"); + } + catch (IOException) + { + return Unknown(request.RequestId, null, "BrokerUnavailable"); + } + catch (JsonException) + { + return Unknown(request.RequestId, null, "InvalidResponse"); + } + catch (BrokerResponseException error) + { + return Unknown(request.RequestId, error.StatusCode, "InvalidResponse"); + } + catch (InvalidOperationException) + { + return Unknown(request.RequestId, null, "InvalidResponse"); + } + catch (ProtocolException) + { + return Unknown(request.RequestId, null, "InvalidResponse"); + } + } + + internal static byte[] CreateOfficialRequest(ElevationRequest request) + { + ArrayBufferWriter buffer = new(); + using Utf8JsonWriter writer = new(buffer); + writer.WriteStartObject(); + writer.WriteString("RequestKind", "PolicyReplacementRequest"); + writer.WriteString("RequestVersion", "1.0"); + writer.WriteString("ExpectedStoreToken", request.ExpectedStoreToken); + writer.WriteString("Operation", request.Operation); + writer.WriteString("ConflictHandling", request.ConflictHandling); + writer.WriteBoolean("WarningsAcknowledged", request.WarningsAcknowledged); + writer.WritePropertyName("Draft"); + request.Draft.WriteTo(writer); + writer.WriteString("ValidationReceipt", request.ValidationReceipt); + writer.WriteEndObject(); + writer.Flush(); + if (buffer.WrittenCount > 16_777_216) + { + throw new ProtocolException("official policy request exceeds broker limit"); + } + return buffer.WrittenSpan.ToArray(); + } + + internal static ElevationResponse ParseResponse(string requestId, byte[] response) + { + ReadOnlySpan delimiter = "\r\n\r\n"u8; + int headerEnd = response.AsSpan().IndexOf(delimiter); + if (headerEnd < 0 || headerEnd > MaximumHeaderBytes) + { + throw new BrokerResponseException(null); + } + + string statusLine = Encoding.ASCII.GetString(response.AsSpan(0, headerEnd)).Split("\r\n", 2)[0]; + string[] statusParts = statusLine.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries); + if (statusParts.Length < 2 || !int.TryParse(statusParts[1], out int status)) + { + throw new BrokerResponseException(null); + } + + ReadOnlyMemory body = response.AsMemory(headerEnd + delimiter.Length); + if (body.IsEmpty) + { + return Unknown(requestId, status, "EmptyResponse"); + } + + try + { + return ParseResponseBody(requestId, status, body); + } + catch (Exception error) when (error is JsonException or InvalidOperationException or ProtocolException) + { + throw new BrokerResponseException(status, error); + } + } + + private static ElevationResponse ParseResponseBody( + string requestId, + int status, + ReadOnlyMemory body) + { + using JsonDocument document = JsonDocument.Parse(body); + JsonElement payload = document.RootElement; + if (status is >= 200 and <= 299) + { + RequireString(payload, "ResponseKind", "PolicyReplacementResponse"); + RequireString(payload, "ResponseVersion", "1.0"); + string token = RequireNestedString(payload, "Management", "StoreToken"); + ElevationResponse committed = new( + Protocol.Version, + requestId, + "Committed", + null, + null, + token, + null, + null, + null); + Protocol.ValidateResponse(committed); + return committed; + } + + RequireString(payload, "ResponseKind", "ErrorResponse"); + RequireString(payload, "ResponseVersion", "1.0"); + string code = RequireString(payload, "Code"); + string? conflictToken = null; + string? conflictState = null; + string? conflictPolicyId = null; + if (code == "StalePolicyStoreToken") + { + JsonElement management = RequireObject(payload, "Management"); + conflictToken = RequireString(management, "StoreToken"); + conflictState = RequireString(management, "State"); + if (conflictState == "Active") + { + JsonElement policy = RequireObject(management, "Policy"); + JsonElement metadata = RequireObject(policy, "Metadata"); + conflictPolicyId = RequireString(metadata, "Id"); + } + } + ElevationResponse rejected = new( + Protocol.Version, + requestId, + "Rejected", + status, + Truncate(code, 64), + null, + conflictToken, + conflictState, + conflictPolicyId); + Protocol.ValidateResponse(rejected); + return rejected; + } + + private static void RequireString(JsonElement payload, string property, string expected) + { + if (!payload.TryGetProperty(property, out JsonElement value) || + value.ValueKind != JsonValueKind.String || + value.GetString() != expected) + { + throw new InvalidOperationException("broker response contract mismatch"); + } + } + + private static string RequireString(JsonElement payload, string property) + { + if (!payload.TryGetProperty(property, out JsonElement value) || + value.ValueKind != JsonValueKind.String || + value.GetString() is not { } result) + { + throw new InvalidOperationException("broker response contract mismatch"); + } + return result; + } + + private static string RequireNestedString(JsonElement payload, string parent, string property) => + RequireString(RequireObject(payload, parent), property); + + private static JsonElement RequireObject(JsonElement payload, string property) + { + if (!payload.TryGetProperty(property, out JsonElement value) || + value.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException("broker response contract mismatch"); + } + return value; + } + + private static async Task ReadBoundedAsync(Stream stream, int maximum, CancellationToken cancellationToken) + { + using MemoryStream response = new(); + byte[] buffer = new byte[16 * 1024]; + while (true) + { + int read = await stream.ReadAsync(buffer, cancellationToken); + if (read == 0) + { + return response.ToArray(); + } + if (response.Length + read > maximum) + { + throw new InvalidOperationException("broker response exceeds limit"); + } + response.Write(buffer, 0, read); + } + } + + private static ElevationResponse Unknown(string requestId, int? statusCode, string errorCode) => + new(Protocol.Version, requestId, "Unknown", statusCode, errorCode, null, null, null, null); + + private static string Truncate(string value, int maximum) => + value.Length <= maximum ? value : value[..maximum]; + + private sealed class BrokerResponseException(int? statusCode, Exception? innerException = null) + : Exception("broker response contract mismatch", innerException) + { + internal int? StatusCode { get; } = statusCode; + } +} diff --git a/package/AgentPolicyConsent/DevolutionsAgentPolicyConsent.csproj b/package/AgentPolicyConsent/DevolutionsAgentPolicyConsent.csproj new file mode 100644 index 000000000..5e5e414e4 --- /dev/null +++ b/package/AgentPolicyConsent/DevolutionsAgentPolicyConsent.csproj @@ -0,0 +1,23 @@ + + + Exe + net10.0-windows + win-x64 + true + true + true + true + false + enable + enable + true + app.manifest + DevolutionsAgentPolicyConsent + DevolutionsAgentPolicyConsent + Devolutions Agent Policy Consent + Devolutions Inc. + + + + + diff --git a/package/AgentPolicyConsent/PeerTrust.cs b/package/AgentPolicyConsent/PeerTrust.cs new file mode 100644 index 000000000..846d151d1 --- /dev/null +++ b/package/AgentPolicyConsent/PeerTrust.cs @@ -0,0 +1,682 @@ +using Microsoft.Win32.SafeHandles; +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +namespace DevolutionsAgentPolicyConsent; + +internal sealed class PeerLease : IDisposable +{ + // SHA-256 digests of accepted UniGetUI signer SPKIs. Keep both keys during certificate rollover. + internal const string TransitionUiSignerSpkiSha256 = + PolicyConsentContract.TransitionUiSignerSpkiSha256; + internal const string CurrentUiSignerSpkiSha256 = + PolicyConsentContract.CurrentUiSignerSpkiSha256; + + internal const uint ProcessQueryLimitedInformation = 0x1000; + internal const uint ProcessQueryInformation = 0x0400; + internal const uint Synchronize = 0x0010_0000; + internal const uint GenericRead = 0x8000_0000; + internal const uint FileExecute = 0x20; + internal const uint FileShareRead = 0x1; + internal const uint OpenExisting = 3; + internal const int ProcessImageFileMapping = 44; + internal const uint StillActive = 259; + + private readonly SafeProcessHandle process; + private readonly SafeFileHandle image; + private readonly int processId; + private readonly long createdUtcTicks; + private readonly uint sessionId; + + private PeerLease( + SafeProcessHandle process, + SafeFileHandle image, + int processId, + long createdUtcTicks, + uint sessionId) + { + this.process = process; + this.image = image; + this.processId = processId; + this.createdUtcTicks = createdUtcTicks; + this.sessionId = sessionId; + } + + internal static PeerLease Open(Arguments arguments) + { + SafeProcessHandle process = Native.OpenProcess( + ProcessQueryInformation | ProcessQueryLimitedInformation | Synchronize, + false, + arguments.ParentProcessId); + if (process.IsInvalid) + { + throw new Win32Exception(); + } + + try + { + long created = CreationTime(process); + uint session = SessionId(arguments.ParentProcessId); + if (!MatchesProcessIdentity( + arguments, + arguments.ParentProcessId, + created, + session)) + { + throw new InvalidOperationException("parent process identity mismatch"); + } + + string path = ImagePath(process); + SafeFileHandle image = Native.CreateFile( + path, + GenericRead | FileExecute | Synchronize, + FileShareRead, + IntPtr.Zero, + OpenExisting, + 0, + IntPtr.Zero); + if (image.IsInvalid) + { + throw new Win32Exception(); + } + + try + { + VerifyImageMapping(process, image); + VerifyImageMetadata(path, image); + using X509Certificate2 signer = VerifyAuthenticodeSigner(path, image, "parent image"); + VerifySigner(signer); + VerifyImageMapping(process, image); + EnsureActive(process); + return new PeerLease(process, image, arguments.ParentProcessId, created, session); + } + catch + { + image.Dispose(); + throw; + } + } + catch + { + process.Dispose(); + throw; + } + } + + internal void VerifyConnectedServer(int serverProcessId) + { + Arguments expected = new(string.Empty, processId, createdUtcTicks, sessionId); + if (!MatchesProcessIdentity(expected, serverProcessId, CreationTime(process), SessionId(serverProcessId))) + { + throw new InvalidOperationException("connected server process identity mismatch"); + } + VerifyImageMapping(process, image); + EnsureActive(process); + } + + internal static bool IsAllowedSigner(string digest) => + FixedTimeEqualsHex(digest, CurrentUiSignerSpkiSha256) || + FixedTimeEqualsHex(digest, TransitionUiSignerSpkiSha256); + + internal static bool IsSupportedUiIdentity(string? productName, string? originalFilename, string? productVersion) => + string.Equals(productName, "UniGetUI", StringComparison.Ordinal) && + string.Equals(originalFilename, "UniGetUI.dll", StringComparison.OrdinalIgnoreCase) && + productVersion is not null && + Version.TryParse(productVersion.Split('+', '-', StringSplitOptions.TrimEntries)[0], out Version? parsed) && + parsed >= new Version(3, 3, 7); + + internal static bool MatchesProcessIdentity( + Arguments expected, + int processId, + long createdUtcTicks, + uint sessionId) => + processId == expected.ParentProcessId && + createdUtcTicks == expected.ParentCreatedUtcTicks && + sessionId == expected.SessionId; + + public void Dispose() + { + image.Dispose(); + process.Dispose(); + } + + private static void VerifyImageMetadata(string path, SafeFileHandle retainedImage) + { + VerifyPathIdentity(path, retainedImage); + if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidOperationException("parent image is a reparse point"); + } + + FileVersionInfo version = FileVersionInfo.GetVersionInfo(path); + if (!IsSupportedUiIdentity(version.ProductName, version.OriginalFilename, version.ProductVersion)) + { + throw new InvalidOperationException("parent image product identity mismatch"); + } + VerifyPathIdentity(path, retainedImage); + } + + private static void VerifyPathIdentity(string path, SafeFileHandle retainedImage) + { + using SafeFileHandle reopened = Native.CreateFile( + path, + GenericRead | FileExecute | Synchronize, + FileShareRead, + IntPtr.Zero, + OpenExisting, + 0, + IntPtr.Zero); + if (reopened.IsInvalid) + { + throw new Win32Exception(); + } + if (!SameFile(retainedImage, reopened)) + { + throw new InvalidOperationException("parent image path no longer identifies the retained image"); + } + } + + internal static bool SameFile(SafeFileHandle left, SafeFileHandle right) + { + if (!Native.GetFileInformationByHandle(left, out Native.ByHandleFileInformation leftInfo) || + !Native.GetFileInformationByHandle(right, out Native.ByHandleFileInformation rightInfo)) + { + throw new Win32Exception(); + } + return leftInfo.VolumeSerialNumber == rightInfo.VolumeSerialNumber && + leftInfo.FileIndexHigh == rightInfo.FileIndexHigh && + leftInfo.FileIndexLow == rightInfo.FileIndexLow; + } + + internal static bool IsLocalSystemProcess(SafeProcessHandle process) + { + if (!Native.OpenProcessToken(process, 0x0008, out SafeAccessTokenHandle token)) + { + throw new Win32Exception(); + } + using (token) + { + _ = Native.GetTokenInformation(token, 1, IntPtr.Zero, 0, out uint length); + if (length == 0) + { + throw new Win32Exception(); + } + + IntPtr information = Marshal.AllocHGlobal(checked((int)length)); + try + { + if (!Native.GetTokenInformation(token, 1, information, length, out _)) + { + throw new Win32Exception(); + } + IntPtr sid = Marshal.ReadIntPtr(information); + return Native.IsWellKnownSid(sid, 22); + } + finally + { + Marshal.FreeHGlobal(information); + } + } + } + + internal static X509Certificate2 VerifyAuthenticodeSigner( + string path, + SafeFileHandle image, + string subject) + { + Guid action = new("00AAC56B-CD44-11d0-8CC2-00C04FC295EE"); + Native.WinTrustFileInfo file = new(path, image.DangerousGetHandle()); + IntPtr filePointer = Marshal.AllocHGlobal(Marshal.SizeOf()); + IntPtr dataPointer = Marshal.AllocHGlobal(Marshal.SizeOf()); + bool fileInitialized = false; + bool dataInitialized = false; + try + { + Marshal.StructureToPtr(file, filePointer, false); + fileInitialized = true; + Native.WinTrustData data = new(filePointer); + Marshal.StructureToPtr(data, dataPointer, false); + dataInitialized = true; + int status = Native.WinVerifyTrust(new IntPtr(-1), ref action, dataPointer); + if (status != 0) + { + throw new InvalidOperationException($"{subject} Authenticode validation failed (0x{status:X8})"); + } + data = Marshal.PtrToStructure(dataPointer); + IntPtr providerData = Native.WTHelperProvDataFromStateData(data.StateData); + IntPtr providerSigner = providerData == IntPtr.Zero + ? IntPtr.Zero + : Native.WTHelperGetProvSignerFromChain(providerData, 0, false, 0); + if (providerSigner == IntPtr.Zero) + { + throw new InvalidOperationException($"{subject} Authenticode signer is unavailable"); + } + + Native.CryptProviderSigner signer = Marshal.PtrToStructure(providerSigner); + if (signer.CertificateChainCount == 0 || signer.CertificateChain == IntPtr.Zero) + { + throw new InvalidOperationException($"{subject} Authenticode certificate chain is empty"); + } + Native.CryptProviderCertificate certificate = + Marshal.PtrToStructure(signer.CertificateChain); +#pragma warning disable SYSLIB0057 // WinVerifyTrust returns the certificate context for the exact retained image. + return new X509Certificate2(certificate.CertificateContext); +#pragma warning restore SYSLIB0057 + } + finally + { + if (dataInitialized) + { + Native.WinTrustData data = Marshal.PtrToStructure(dataPointer); + if (data.StateData != IntPtr.Zero) + { + data.StateAction = 2; + Marshal.StructureToPtr(data, dataPointer, true); + _ = Native.WinVerifyTrust(new IntPtr(-1), ref action, dataPointer); + } + Marshal.DestroyStructure(dataPointer); + } + if (fileInitialized) + { + Marshal.DestroyStructure(filePointer); + } + Marshal.FreeHGlobal(dataPointer); + Marshal.FreeHGlobal(filePointer); + } + } + + private static void VerifySigner(X509Certificate2 certificate) + { + byte[] subjectPublicKeyInfo; + using (RSA? rsa = certificate.GetRSAPublicKey()) + { + if (rsa is not null) + { + subjectPublicKeyInfo = rsa.ExportSubjectPublicKeyInfo(); + } + else + { + using ECDsa? ecdsa = certificate.GetECDsaPublicKey(); + subjectPublicKeyInfo = ecdsa?.ExportSubjectPublicKeyInfo() + ?? throw new InvalidOperationException("unsupported parent signer key"); + } + } + + string digest = Convert.ToHexString(SHA256.HashData(subjectPublicKeyInfo)).ToLowerInvariant(); + if (!IsAllowedSigner(digest)) + { + throw new InvalidOperationException("parent image signer is not authorized"); + } + } + + private static bool FixedTimeEqualsHex(string candidate, string expected) + { + if (candidate.Length != 64 || + expected.Length != 64 || + candidate.AsSpan().IndexOfAnyExcept("0123456789abcdef") >= 0) + { + return false; + } + try + { + return CryptographicOperations.FixedTimeEquals( + Convert.FromHexString(candidate), + Convert.FromHexString(expected)); + } + catch (FormatException) + { + return false; + } + } + + internal static void VerifyImageMapping(SafeProcessHandle process, SafeFileHandle image) + { + IntPtr fileHandle = image.DangerousGetHandle(); + int status = Native.NtQueryInformationProcess( + process.DangerousGetHandle(), + ProcessImageFileMapping, + ref fileHandle, + IntPtr.Size, + out _); + if (status != 0) + { + throw new InvalidOperationException($"parent image mapping mismatch (0x{status:X8})"); + } + } + + internal static string ImagePath(SafeProcessHandle process) + { + int capacity = 260; + while (capacity <= 32_768) + { + StringBuilder path = new(capacity); + int length = capacity; + if (Native.QueryFullProcessImageName(process, 0, path, ref length)) + { + return path.ToString(); + } + if (Marshal.GetLastWin32Error() != 122) + { + throw new Win32Exception(); + } + capacity *= 2; + } + throw new InvalidOperationException("parent image path is too long"); + } + + private static long CreationTime(SafeProcessHandle process) + { + if (!Native.GetProcessTimes(process, out long created, out _, out _, out _)) + { + throw new Win32Exception(); + } + return DateTime.FromFileTimeUtc(created).Ticks; + } + + private static uint SessionId(int processId) + { + if (!Native.ProcessIdToSessionId(processId, out uint sessionId)) + { + throw new Win32Exception(); + } + return sessionId; + } + + internal static void EnsureActive(SafeProcessHandle process) + { + if (!Native.GetExitCodeProcess(process, out uint exitCode)) + { + throw new Win32Exception(); + } + if (exitCode != StillActive) + { + throw new InvalidOperationException("parent process exited"); + } + } +} + +internal sealed class BrokerServerLease : IDisposable +{ + private const string AgentExecutableName = "DevolutionsAgent.exe"; + + private readonly SafeProcessHandle process; + private readonly SafeFileHandle image; + + private BrokerServerLease(SafeProcessHandle process, SafeFileHandle image) + { + this.process = process; + this.image = image; + } + + internal static BrokerServerLease Open(SafePipeHandle pipe) + { + if (!Native.GetNamedPipeServerProcessId(pipe, out int processId)) + { + throw new Win32Exception(); + } + + SafeProcessHandle process = Native.OpenProcess( + PeerLease.ProcessQueryLimitedInformation | PeerLease.Synchronize, + false, + processId); + if (process.IsInvalid) + { + throw new Win32Exception(); + } + + try + { + if (!PeerLease.IsLocalSystemProcess(process)) + { + throw new InvalidOperationException("broker server is not running as LocalSystem"); + } + string helperPath = Environment.ProcessPath + ?? throw new InvalidOperationException("helper executable path is unavailable"); + string expectedPath = Path.Combine( + Path.GetDirectoryName(helperPath) + ?? throw new InvalidOperationException("helper installation directory is unavailable"), + AgentExecutableName); + string serverPath = PeerLease.ImagePath(process); + if (!IsExpectedPath(serverPath, expectedPath)) + { + throw new InvalidOperationException("broker server is not the installed Agent"); + } + + SafeFileHandle image = Native.CreateFile( + expectedPath, + PeerLease.GenericRead | PeerLease.FileExecute | PeerLease.Synchronize, + PeerLease.FileShareRead, + IntPtr.Zero, + PeerLease.OpenExisting, + 0, + IntPtr.Zero); + if (image.IsInvalid) + { + throw new Win32Exception(); + } + + try + { + using X509Certificate2 _ = + PeerLease.VerifyAuthenticodeSigner(expectedPath, image, "broker server"); + PeerLease.EnsureActive(process); + return new BrokerServerLease(process, image); + } + catch + { + image.Dispose(); + throw; + } + } + catch + { + process.Dispose(); + throw; + } + } + + internal static bool IsExpectedPath(string actual, string expected) => + string.Equals( + Path.GetFullPath(actual), + Path.GetFullPath(expected), + StringComparison.OrdinalIgnoreCase); + + public void Dispose() + { + image.Dispose(); + process.Dispose(); + } +} + +internal static partial class Native +{ + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + internal readonly struct WinTrustFileInfo + { + internal readonly uint StructSize; + [MarshalAs(UnmanagedType.LPWStr)] + internal readonly string FilePath; + internal readonly IntPtr FileHandle; + internal readonly IntPtr KnownSubject; + + internal WinTrustFileInfo(string filePath, IntPtr fileHandle) + { + StructSize = checked((uint)Marshal.SizeOf()); + FilePath = filePath; + FileHandle = fileHandle; + KnownSubject = IntPtr.Zero; + } + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + internal struct WinTrustData + { + internal uint StructSize; + internal IntPtr PolicyCallbackData; + internal IntPtr SipClientData; + internal uint UiChoice; + internal uint RevocationChecks; + internal uint UnionChoice; + internal IntPtr FileInfo; + internal uint StateAction; + internal IntPtr StateData; + internal IntPtr UrlReference; + internal uint ProviderFlags; + internal uint UiContext; + internal IntPtr SignatureSettings; + + internal WinTrustData(IntPtr fileInfo) + { + StructSize = checked((uint)Marshal.SizeOf()); + PolicyCallbackData = IntPtr.Zero; + SipClientData = IntPtr.Zero; + UiChoice = 2; + RevocationChecks = 0; + UnionChoice = 1; + FileInfo = fileInfo; + StateAction = 1; + StateData = IntPtr.Zero; + UrlReference = IntPtr.Zero; + ProviderFlags = 0x1000 | 0x2000; + UiContext = 0; + SignatureSettings = IntPtr.Zero; + } + } + + [StructLayout(LayoutKind.Sequential)] + internal readonly struct CryptProviderSigner + { + internal readonly uint StructSize; + internal readonly NativeFileTime VerifyAsOf; + internal readonly uint CertificateChainCount; + internal readonly IntPtr CertificateChain; + internal readonly uint SignerType; + internal readonly IntPtr SignerInfo; + internal readonly uint Error; + internal readonly uint CounterSignerCount; + internal readonly IntPtr CounterSigners; + internal readonly IntPtr ChainContext; + } + + [StructLayout(LayoutKind.Sequential)] + internal readonly struct NativeFileTime + { + internal readonly uint LowDateTime; + internal readonly uint HighDateTime; + } + + [StructLayout(LayoutKind.Sequential)] + internal readonly struct ByHandleFileInformation + { + internal readonly uint FileAttributes; + internal readonly NativeFileTime CreationTime; + internal readonly NativeFileTime LastAccessTime; + internal readonly NativeFileTime LastWriteTime; + internal readonly uint VolumeSerialNumber; + internal readonly uint FileSizeHigh; + internal readonly uint FileSizeLow; + internal readonly uint NumberOfLinks; + internal readonly uint FileIndexHigh; + internal readonly uint FileIndexLow; + } + + [StructLayout(LayoutKind.Sequential)] + internal readonly struct CryptProviderCertificate + { + internal readonly uint StructSize; + internal readonly IntPtr CertificateContext; + } + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool QueryFullProcessImageName( + SafeProcessHandle process, + uint flags, + [Out] StringBuilder path, + ref int size); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetProcessTimes( + SafeProcessHandle process, + out long creation, + out long exit, + out long kernel, + out long user); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool ProcessIdToSessionId(int processId, out uint sessionId); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetExitCodeProcess(SafeProcessHandle process, out uint exitCode); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetFileInformationByHandle( + SafeFileHandle file, + out ByHandleFileInformation information); + + [LibraryImport("advapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool OpenProcessToken( + SafeProcessHandle process, + uint desiredAccess, + out SafeAccessTokenHandle token); + + [LibraryImport("advapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetTokenInformation( + SafeAccessTokenHandle token, + int informationClass, + IntPtr information, + uint informationLength, + out uint returnLength); + + [LibraryImport("advapi32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool IsWellKnownSid(IntPtr sid, int wellKnownSidType); + + [LibraryImport("kernel32.dll", EntryPoint = "CreateFileW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] + internal static partial SafeFileHandle CreateFile( + string fileName, + uint desiredAccess, + uint shareMode, + IntPtr securityAttributes, + uint creationDisposition, + uint flagsAndAttributes, + IntPtr templateFile); + + [LibraryImport("kernel32.dll", SetLastError = true)] + internal static partial SafeProcessHandle OpenProcess(uint desiredAccess, [MarshalAs(UnmanagedType.Bool)] bool inherit, int processId); + + [LibraryImport("ntdll.dll")] + internal static partial int NtQueryInformationProcess( + IntPtr process, + int informationClass, + ref IntPtr information, + int informationLength, + out int returnLength); + + [LibraryImport("wintrust.dll", SetLastError = true)] + internal static partial int WinVerifyTrust(IntPtr window, ref Guid action, IntPtr data); + + [LibraryImport("wintrust.dll")] + internal static partial IntPtr WTHelperProvDataFromStateData(IntPtr stateData); + + [LibraryImport("wintrust.dll")] + internal static partial IntPtr WTHelperGetProvSignerFromChain( + IntPtr providerData, + uint signerIndex, + [MarshalAs(UnmanagedType.Bool)] bool counterSigner, + uint counterSignerIndex); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetNamedPipeServerProcessId(SafePipeHandle pipe, out int processId); +} diff --git a/package/AgentPolicyConsent/PolicyConsentContract.cs b/package/AgentPolicyConsent/PolicyConsentContract.cs new file mode 100644 index 000000000..01789bbf5 --- /dev/null +++ b/package/AgentPolicyConsent/PolicyConsentContract.cs @@ -0,0 +1,13 @@ +namespace DevolutionsAgentPolicyConsent +{ + internal static class PolicyConsentContract + { + internal const string ProtocolVersion = "2.0"; + internal const string ExecutableName = "DevolutionsAgentPolicyConsent.exe"; + internal const string ProductName = "Devolutions Agent Policy Consent"; + internal const string CurrentUiSignerSpkiSha256 = + "e43ed3368eaabff61abc79eb338cba9da88a80d93b751735ff417f26afa579a8"; + internal const string TransitionUiSignerSpkiSha256 = + "99e7adb5894e242d87d32b8ad6cb5a1e0d2dd791a447bd7192c30189ef083fab"; + } +} diff --git a/package/AgentPolicyConsent/Program.cs b/package/AgentPolicyConsent/Program.cs new file mode 100644 index 000000000..2ec726a4f --- /dev/null +++ b/package/AgentPolicyConsent/Program.cs @@ -0,0 +1,173 @@ +using System.IO.Pipes; +using System.Security.Principal; +using System.Text.Json; + +namespace DevolutionsAgentPolicyConsent; + +internal static class Program +{ + private const int Success = 0; + private const int InvalidArguments = 10; + private const int ConnectionFailure = 11; + private const int PeerAuthenticationFailure = 12; + private const int ProtocolFailure = 13; + private const int UnexpectedFailure = 14; + + private static async Task Main(string[] args) + { + if (!OperatingSystem.IsWindows()) + { + return InvalidArguments; + } + + Arguments arguments; + try + { + arguments = Protocol.ParseArguments(args); + } + catch (ProtocolException) + { + return InvalidArguments; + } + + using CancellationTokenSource handshake = new(Protocol.ConnectTimeout); + PeerLease peer; + try + { + peer = await OpenPeerAsync(arguments, handshake.Token); + } + catch (OperationCanceledException) + { + return ConnectionFailure; + } + catch + { + return PeerAuthenticationFailure; + } + + using (peer) + using (NamedPipeClientStream pipe = new( + ".", + arguments.PipeName, + PipeDirection.InOut, + PipeOptions.Asynchronous | PipeOptions.WriteThrough, + TokenImpersonationLevel.Anonymous)) + { + try + { + await pipe.ConnectAsync(handshake.Token); + if (!Native.GetNamedPipeServerProcessId(pipe.SafePipeHandle, out int serverProcessId)) + { + return PeerAuthenticationFailure; + } + peer.VerifyConnectedServer(serverProcessId); + byte[] body = await Protocol.ReadFrameAsync(pipe, Protocol.MaxRequestBodyBytes, handshake.Token); + ElevationRequest request = JsonSerializer.Deserialize(body, ProtocolJsonContext.Default.ElevationRequest) + ?? throw new ProtocolException("request is null"); + Protocol.ValidateRequest(request); + peer.VerifyConnectedServer(serverProcessId); + + using CancellationTokenSource exchange = new(Protocol.ExchangeTimeout); + using CancellationTokenSource brokerCancellation = + CancellationTokenSource.CreateLinkedTokenSource(exchange.Token); + using CancellationTokenSource monitorCancellation = + CancellationTokenSource.CreateLinkedTokenSource(exchange.Token); + Task monitor = MonitorHostAsync(pipe, brokerCancellation, monitorCancellation.Token); + ElevationResponse response = await BrokerClient.ReplaceAsync(request, brokerCancellation.Token); + monitorCancellation.Cancel(); + await IgnoreCancellationAsync(monitor); + Protocol.ValidateResponse(response); + + byte[] responseBody = JsonSerializer.SerializeToUtf8Bytes( + response, + ProtocolJsonContext.Default.ElevationResponse); + using CancellationTokenSource responseWrite = new(Protocol.ResponseWriteTimeout); + await Protocol.WriteFrameAsync( + pipe, + responseBody, + Protocol.MaxResponseBodyBytes, + responseWrite.Token); + return Success; + } + catch (OperationCanceledException) + { + return ConnectionFailure; + } + catch (IOException) + { + return ConnectionFailure; + } + catch (ProtocolException) + { + return ProtocolFailure; + } + catch (JsonException) + { + return ProtocolFailure; + } + catch + { + return UnexpectedFailure; + } + } + } + + private static async Task OpenPeerAsync(Arguments arguments, CancellationToken cancellationToken) + { + Task open = Task.Run(() => PeerLease.Open(arguments)); + try + { + return await open.WaitAsync(cancellationToken); + } + catch + { + _ = open.ContinueWith( + static completed => + { + if (completed.Status == TaskStatus.RanToCompletion) + { + completed.Result.Dispose(); + } + _ = completed.Exception; + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + throw; + } + } + + private static async Task MonitorHostAsync( + Stream pipe, + CancellationTokenSource brokerCancellation, + CancellationToken cancellationToken) + { + byte[] unexpected = new byte[1]; + try + { + int read = await pipe.ReadAsync(unexpected, cancellationToken); + if (read is 0 or 1) + { + brokerCancellation.Cancel(); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (IOException) + { + brokerCancellation.Cancel(); + } + } + + private static async Task IgnoreCancellationAsync(Task task) + { + try + { + await task; + } + catch (OperationCanceledException) + { + } + } +} diff --git a/package/AgentPolicyConsent/Protocol.cs b/package/AgentPolicyConsent/Protocol.cs new file mode 100644 index 000000000..04184f3dc --- /dev/null +++ b/package/AgentPolicyConsent/Protocol.cs @@ -0,0 +1,240 @@ +using System.Buffers.Binary; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace DevolutionsAgentPolicyConsent; + +internal static class Protocol +{ + internal const string Version = PolicyConsentContract.ProtocolVersion; + internal const string PipePrefix = "UniGetUI.PolicyElevation."; + internal const int MaxRequestBodyBytes = 16_793_054; + internal const int MaxResponseBodyBytes = 15_618; + internal static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(45); + internal static readonly TimeSpan ExchangeTimeout = TimeSpan.FromMinutes(2); + internal static readonly TimeSpan ResponseWriteTimeout = TimeSpan.FromSeconds(10); + + internal static Arguments ParseArguments(string[] args) + { + if (args.Length != 10) + { + throw new ProtocolException("invalid argument count"); + } + + Dictionary values = new(StringComparer.Ordinal); + for (int index = 0; index < args.Length; index += 2) + { + if (!values.TryAdd(args[index], args[index + 1])) + { + throw new ProtocolException("duplicate argument"); + } + } + + string protocol = Required(values, "--protocol"); + string pipe = Required(values, "--pipe"); + if (protocol != Version || + !pipe.StartsWith(PipePrefix, StringComparison.Ordinal) || + !IsLowerHex(pipe.AsSpan(PipePrefix.Length), 32) || + !int.TryParse( + Required(values, "--parent-pid"), + NumberStyles.None, + CultureInfo.InvariantCulture, + out int parentPid) || + parentPid <= 0 || + !long.TryParse( + Required(values, "--parent-created"), + NumberStyles.None, + CultureInfo.InvariantCulture, + out long parentCreated) || + parentCreated <= 0 || + !uint.TryParse( + Required(values, "--session"), + NumberStyles.None, + CultureInfo.InvariantCulture, + out uint session) || + values.Count != 5) + { + throw new ProtocolException("invalid argument value"); + } + + return new Arguments(pipe, parentPid, parentCreated, session); + } + + internal static void ValidateRequest(ElevationRequest request) + { + if (request.ProtocolVersion != Version || + !IsLowerHex(request.RequestId.AsSpan(), 32) || + request.Operation is not ("Update" or "ReplaceIdentity" or "Create" or "Repair") || + request.ConflictHandling is not ("Reject" or "ConfirmOverwrite") || + !IsCredential(request.ExpectedStoreToken, 512) || + !IsCredential(request.ValidationReceipt, 2048) || + request.Draft.ValueKind != JsonValueKind.Object) + { + throw new ProtocolException("invalid request"); + } + } + + internal static void ValidateResponse(ElevationResponse response) + { + if (response.ProtocolVersion != Version || + !IsLowerHex(response.RequestId.AsSpan(), 32) || + response.Disposition is not ("Committed" or "Rejected" or "Unknown") || + !IsOptionalCredential(response.BrokerErrorCode, 64)) + { + throw new ProtocolException("invalid response"); + } + + bool hasConflict = + response.ConflictStoreToken is not null || + response.ConflictState is not null || + response.ConflictPolicyId is not null; + switch (response.Disposition) + { + case "Committed" when + response.BrokerStatusCode is null && + response.BrokerErrorCode is null && + IsCredential(response.CommittedStoreToken, 512) && + !hasConflict: + return; + case "Rejected" when + response.CommittedStoreToken is null && + response.BrokerErrorCode is not null: + ValidateConflict(response, hasConflict); + return; + case "Unknown" when + response.CommittedStoreToken is null && + response.BrokerErrorCode is not null && + !hasConflict: + return; + default: + throw new ProtocolException("invalid response shape"); + } + } + + internal static async Task ReadFrameAsync(Stream stream, int maximum, CancellationToken cancellationToken) + { + byte[] header = new byte[4]; + await ReadExactlyAsync(stream, header, cancellationToken); + uint length = BinaryPrimitives.ReadUInt32BigEndian(header); + if (length == 0 || length > maximum) + { + throw new ProtocolException("invalid frame length"); + } + + byte[] body = GC.AllocateUninitializedArray(checked((int)length)); + await ReadExactlyAsync(stream, body, cancellationToken); + return body; + } + + internal static async Task WriteFrameAsync( + Stream stream, + ReadOnlyMemory body, + int maximum, + CancellationToken cancellationToken) + { + if (body.IsEmpty || body.Length > maximum) + { + throw new ProtocolException("invalid frame length"); + } + + byte[] header = new byte[4]; + BinaryPrimitives.WriteUInt32BigEndian(header, checked((uint)body.Length)); + await stream.WriteAsync(header, cancellationToken); + await stream.WriteAsync(body, cancellationToken); + await stream.FlushAsync(cancellationToken); + } + + private static async Task ReadExactlyAsync(Stream stream, Memory buffer, CancellationToken cancellationToken) + { + int offset = 0; + while (offset < buffer.Length) + { + int read = await stream.ReadAsync(buffer[offset..], cancellationToken); + if (read == 0) + { + throw new EndOfStreamException("unexpected end of elevation frame"); + } + offset += read; + } + } + + private static string Required(Dictionary values, string key) => + values.TryGetValue(key, out string? value) && value.Length != 0 + ? value + : throw new ProtocolException("missing argument"); + + private static bool IsLowerHex(ReadOnlySpan value, int length) => + value.Length == length && value.IndexOfAnyExcept("0123456789abcdef") < 0; + + internal static bool IsCredential(string? value, int maximum) => + value is not null && + value.Length is > 0 && + value.Length <= maximum && + IsAsciiAlphaNumeric(value[0]) && + value.AsSpan(1).IndexOfAnyExceptInRange('!', '~') < 0; + + private static bool IsOptionalCredential(string? value, int maximum) => + value is null || IsCredential(value, maximum); + + private static void ValidateConflict(ElevationResponse response, bool hasConflict) + { + if (response.BrokerErrorCode != "StalePolicyStoreToken") + { + if (hasConflict) + { + throw new ProtocolException("non-stale response carries conflict fields"); + } + return; + } + + if (!IsCredential(response.ConflictStoreToken, 512) || + response.ConflictState is not ("Active" or "Missing" or "Invalid") || + (response.ConflictState == "Active" + ? !IsCredential(response.ConflictPolicyId, 2048) + : response.ConflictPolicyId is not null)) + { + throw new ProtocolException("invalid stale conflict"); + } + } + + private static bool IsAsciiAlphaNumeric(char value) => + value is >= '0' and <= '9' or >= 'A' and <= 'Z' or >= 'a' and <= 'z'; +} + +internal sealed record Arguments(string PipeName, int ParentProcessId, long ParentCreatedUtcTicks, uint SessionId); + +[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +internal sealed record ElevationRequest( + [property: JsonRequired] string ProtocolVersion, + [property: JsonRequired] string RequestId, + [property: JsonRequired] string Operation, + [property: JsonRequired] string ConflictHandling, + [property: JsonRequired] string ExpectedStoreToken, + [property: JsonRequired] string ValidationReceipt, + [property: JsonRequired] bool WarningsAcknowledged, + [property: JsonRequired] JsonElement Draft); + +[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +internal sealed record ElevationResponse( + [property: JsonRequired] string ProtocolVersion, + [property: JsonRequired] string RequestId, + [property: JsonRequired] string Disposition, + [property: JsonRequired] int? BrokerStatusCode, + [property: JsonRequired] string? BrokerErrorCode, + [property: JsonRequired] string? CommittedStoreToken, + [property: JsonRequired] string? ConflictStoreToken, + [property: JsonRequired] string? ConflictState, + [property: JsonRequired] string? ConflictPolicyId); + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = false, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + GenerationMode = JsonSourceGenerationMode.Metadata)] +[JsonSerializable(typeof(ElevationRequest))] +[JsonSerializable(typeof(ElevationResponse))] +internal sealed partial class ProtocolJsonContext : JsonSerializerContext; + +internal sealed class ProtocolException(string message) : Exception(message); diff --git a/package/AgentPolicyConsent/app.manifest b/package/AgentPolicyConsent/app.manifest new file mode 100644 index 000000000..d303ea813 --- /dev/null +++ b/package/AgentPolicyConsent/app.manifest @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs b/package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs new file mode 100644 index 000000000..a17e10bef --- /dev/null +++ b/package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs @@ -0,0 +1,45 @@ +using System; +using System.Reflection; + +using WixSharp; + +using Xunit; + +namespace DevolutionsAgent.Installer.Tests; + +public sealed class PolicyConsentDiscoveryTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void DiscoveryIsTransactionalAndArchitectureCorrect(bool win64) + { + RegValue value = CreateDiscoveryValue("ProtocolVersion", "2.0", win64); + + Assert.Equal(RegistryHive.LocalMachine, value.Root); + Assert.Equal(@"Software\Devolutions\Agent\PolicyConsentHelper", value.Key); + Assert.Equal(RegistryKeyAction.createAndRemoveOnUninstall, value.RegistryKeyAction); + Assert.Equal(win64, value.Win64); + } + + [Fact] + public void DiscoveryPublishesTheFixedHelperIdentity() + { + RegValue value = CreateDiscoveryValue( + "ExecutablePath", + "[INSTALLDIR]DevolutionsAgentPolicyConsent.exe", + true); + + Assert.Equal("[INSTALLDIR]DevolutionsAgentPolicyConsent.exe", value.Value); + Assert.Equal(RegistryKeyAction.createAndRemoveOnUninstall, value.RegistryKeyAction); + } + + private static RegValue CreateDiscoveryValue(string name, string value, bool win64) + { + Type program = Assembly.Load("DevolutionsAgent").GetType("DevolutionsAgent.Program", throwOnError: true); + MethodInfo method = program.GetMethod( + "CreatePolicyConsentRegistryValue", + BindingFlags.Static | BindingFlags.NonPublic); + return Assert.IsType(method.Invoke(null, [name, value, win64])); + } +} diff --git a/package/AgentWindowsManaged/DevolutionsAgent.csproj b/package/AgentWindowsManaged/DevolutionsAgent.csproj index 527dd4bd4..6df8d0103 100644 --- a/package/AgentWindowsManaged/DevolutionsAgent.csproj +++ b/package/AgentWindowsManaged/DevolutionsAgent.csproj @@ -6,6 +6,7 @@ latest + diff --git a/package/AgentWindowsManaged/Program.cs b/package/AgentWindowsManaged/Program.cs index f9ff4595a..021d3cfb9 100644 --- a/package/AgentWindowsManaged/Program.cs +++ b/package/AgentWindowsManaged/Program.cs @@ -77,6 +77,10 @@ private static string ResolveArtifact(string varName, string defaultPath = null) private static string DevolutionsAgentExePath => ResolveArtifact("DAGENT_EXECUTABLE", "..\\..\\target\\debug\\devolutions-agent.exe"); + private static string DevolutionsAgentPolicyConsentPath => ResolveArtifact( + "DAGENT_POLICY_CONSENT_HELPER", + "..\\AgentPolicyConsent\\bin\\Release\\net10.0-windows\\win-x64\\publish\\DevolutionsAgentPolicyConsent.exe"); + private static string DevolutionsDesktopAgentPath { // ReSharper disable once ArrangeAccessorOwnerBody @@ -123,6 +127,12 @@ private static Version DevolutionsAgentVersion } } + // The MSI version drops the "20" prefix; discovery restores the calendar-year product version. + private static Version DevolutionsAgentProductVersion => new( + DevolutionsAgentVersion.Major + 2000, + DevolutionsAgentVersion.Minor, + DevolutionsAgentVersion.Build); + private static WixSharp.Platform TargetPlatform { get @@ -298,6 +308,10 @@ static void Main() new (Features.AGENT_FEATURE, DevolutionsMultiPwshExe) { TargetFileName = "multi-pwsh.exe" + }, + new (Features.AGENT_FEATURE, DevolutionsAgentPolicyConsentPath) + { + TargetFileName = Includes.POLICY_CONSENT_EXECUTABLE_NAME } }, Dirs = new[] @@ -325,6 +339,30 @@ static void Main() Win64 = project.Platform == Platform.x64, RegistryKeyAction = RegistryKeyAction.create, }, + CreatePolicyConsentRegistryValue( + "ProtocolVersion", + Includes.POLICY_CONSENT_PROTOCOL_VERSION, + Use64BitRegistryView(project.Platform)), + CreatePolicyConsentRegistryValue( + "ExecutableName", + Includes.POLICY_CONSENT_EXECUTABLE_NAME, + Use64BitRegistryView(project.Platform)), + CreatePolicyConsentRegistryValue( + "ExecutablePath", + $"[{AgentProperties.InstallDir}]{Includes.POLICY_CONSENT_EXECUTABLE_NAME}", + Use64BitRegistryView(project.Platform)), + CreatePolicyConsentRegistryValue( + "ProductName", + Includes.POLICY_CONSENT_PRODUCT_NAME, + Use64BitRegistryView(project.Platform)), + CreatePolicyConsentRegistryValue( + "ProductVersion", + DevolutionsAgentProductVersion.ToString(), + Use64BitRegistryView(project.Platform)), + CreatePolicyConsentRegistryValue( + "CurrentUiSignerSpkiSha256", + Includes.POLICY_CONSENT_CURRENT_UI_SIGNER_SPKI_SHA256, + Use64BitRegistryView(project.Platform)), new (RegistryHive.LocalMachine, "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run", Includes.SERVICE_NAME, $"[{AgentProperties.InstallDir}]{Includes.DESKTOP_DIRECTORY_NAME}\\{Includes.DESKTOP_EXECUTABLE_NAME}") { Win64 = project.Platform == Platform.x64, @@ -438,6 +476,23 @@ internal static RegValue CreateEventLogSourceRegistryValue(bool win64) => RegistryKeyAction = RegistryKeyAction.create, }; + internal static RegValue CreatePolicyConsentRegistryValue(string name, string value, bool win64) => + new( + RegistryHive.LocalMachine, + $"Software\\{Includes.VENDOR_NAME}\\{Includes.SHORT_NAME}\\PolicyConsentHelper", + name, + value) + { + AttributesDefinition = "Type=string", + Win64 = win64, + RegistryKeyAction = RegistryKeyAction.createAndRemoveOnUninstall, + Feature = Features.AGENT_FEATURE, + }; + + // Discovery follows the consumer's native registry view. + internal static bool Use64BitRegistryView(Platform? platform) => + platform is Platform.x64 or Platform.arm64; + private static void Project_UnhandledException(ExceptionEventArgs e) { string errorMessage = diff --git a/package/AgentWindowsManaged/Resources/Includes.cs b/package/AgentWindowsManaged/Resources/Includes.cs index 5ee9e12ae..284f8bef2 100644 --- a/package/AgentWindowsManaged/Resources/Includes.cs +++ b/package/AgentWindowsManaged/Resources/Includes.cs @@ -18,6 +18,18 @@ internal static class Includes internal static readonly string EXECUTABLE_NAME = "DevolutionsAgent.exe"; + internal static readonly string POLICY_CONSENT_EXECUTABLE_NAME = + DevolutionsAgentPolicyConsent.PolicyConsentContract.ExecutableName; + + internal static readonly string POLICY_CONSENT_PRODUCT_NAME = + DevolutionsAgentPolicyConsent.PolicyConsentContract.ProductName; + + internal static readonly string POLICY_CONSENT_PROTOCOL_VERSION = + DevolutionsAgentPolicyConsent.PolicyConsentContract.ProtocolVersion; + + internal static readonly string POLICY_CONSENT_CURRENT_UI_SIGNER_SPKI_SHA256 = + DevolutionsAgentPolicyConsent.PolicyConsentContract.CurrentUiSignerSpkiSha256; + internal static readonly string DESKTOP_DIRECTORY_NAME = "desktop"; internal static readonly string DESKTOP_EXECUTABLE_NAME = "DevolutionsDesktopAgent.exe"; From c1b5c48b8fb03a0cee327f4cd3389cf0de90fafd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 9 Sep 2026 16:10:01 -0400 Subject: [PATCH 16/39] fix(agent): enforce signer revocation checks Require fresh whole-chain WinTrust revocation status before authorizing the UniGetUI parent or Agent broker. Bound broker trust retrieval to the existing exchange timeout and reject all pre-dispatch authentication failures. Issue: #1963 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ci/package-agent-windows.ps1 | 2 +- .../AgentPolicyConsent.Tests/ProtocolTests.cs | 23 ++++++++++ package/AgentPolicyConsent/BrokerClient.cs | 44 ++++++++++++++++++- package/AgentPolicyConsent/PeerTrust.cs | 13 ++++-- 4 files changed, 77 insertions(+), 5 deletions(-) diff --git a/ci/package-agent-windows.ps1 b/ci/package-agent-windows.ps1 index 10f0e7219..246cfe245 100644 --- a/ci/package-agent-windows.ps1 +++ b/ci/package-agent-windows.ps1 @@ -100,7 +100,7 @@ function New-AgentMsi() { # The path to the devolutions-session.exe file. [string] $SessionExe, [parameter(Mandatory = $true)] - # The path to the DevolutionsAgentPolicyConsent.exe file. + # The path to the signed DevolutionsAgentPolicyConsent.exe file. [string] $PolicyConsentHelper, [parameter(Mandatory = $true)] [ValidateSet('x64', 'arm64')] diff --git a/package/AgentPolicyConsent.Tests/ProtocolTests.cs b/package/AgentPolicyConsent.Tests/ProtocolTests.cs index 36456f288..e5e0a21ca 100644 --- a/package/AgentPolicyConsent.Tests/ProtocolTests.cs +++ b/package/AgentPolicyConsent.Tests/ProtocolTests.cs @@ -101,6 +101,29 @@ public void AuthenticodeSignerComesFromRetainedImageHandle() Assert.NotEmpty(certificate.RawData); } + [Fact] + public void AuthenticodeRequiresFreshWholeChainRevocation() + { + Native.WinTrustData data = new(IntPtr.Zero); + + Assert.Equal(Native.WtdRevokeWholeChain, data.RevocationChecks); + Assert.Equal( + Native.WtdRevocationCheckChain | Native.WtdDisableMd2Md4, + data.ProviderFlags); + Assert.Equal(0u, data.ProviderFlags & Native.WtdCacheOnlyUrlRetrieval); + } + + [Theory] + [InlineData(0, true)] + [InlineData(unchecked((int)0x800B010C), false)] + [InlineData(unchecked((int)0x80092012), false)] + [InlineData(unchecked((int)0x80092013), false)] + [InlineData(unchecked((int)0x800B010E), false)] + public void AuthenticodeFailsClosedForIndeterminateStatus(int status, bool accepted) + { + Assert.Equal(accepted, PeerLease.IsAuthenticodeStatusAccepted(status)); + } + [Fact] public void ProcessImageMappingAcceptsOnlyCurrentMappedImage() { diff --git a/package/AgentPolicyConsent/BrokerClient.cs b/package/AgentPolicyConsent/BrokerClient.cs index bdcbd5120..4a1d5affc 100644 --- a/package/AgentPolicyConsent/BrokerClient.cs +++ b/package/AgentPolicyConsent/BrokerClient.cs @@ -29,7 +29,7 @@ internal static async Task ReplaceAsync( using CancellationTokenSource connectTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); connectTimeout.CancelAfter(ConnectTimeout); await pipe.ConnectAsync(connectTimeout.Token); - using BrokerServerLease broker = BrokerServerLease.Open(pipe.SafePipeHandle); + using BrokerServerLease broker = await OpenBrokerServerAsync(pipe, cancellationToken); byte[] headers = Encoding.ASCII.GetBytes( $"PUT /v1/policy HTTP/1.1\r\nHost: now-package-broker\r\nConnection: close\r\n" + @@ -60,6 +60,10 @@ internal static async Task ReplaceAsync( { return Unknown(request.RequestId, error.StatusCode, "InvalidResponse"); } + catch (BrokerAuthenticationException) + { + return Rejected(request.RequestId, "Unauthorized"); + } catch (InvalidOperationException) { return Unknown(request.RequestId, null, "InvalidResponse"); @@ -239,6 +243,41 @@ private static async Task ReadBoundedAsync(Stream stream, int maximum, C private static ElevationResponse Unknown(string requestId, int? statusCode, string errorCode) => new(Protocol.Version, requestId, "Unknown", statusCode, errorCode, null, null, null, null); + private static ElevationResponse Rejected(string requestId, string errorCode) => + new(Protocol.Version, requestId, "Rejected", null, errorCode, null, null, null, null); + + private static async Task OpenBrokerServerAsync( + NamedPipeClientStream pipe, + CancellationToken cancellationToken) + { + Task open = Task.Run(() => BrokerServerLease.Open(pipe.SafePipeHandle)); + try + { + return await open.WaitAsync(cancellationToken); + } + catch (Exception error) + { + DisposeLateResult(open); + throw new BrokerAuthenticationException(error); + } + } + + private static void DisposeLateResult(Task open) + { + _ = open.ContinueWith( + static completed => + { + if (completed.Status == TaskStatus.RanToCompletion) + { + completed.Result.Dispose(); + } + _ = completed.Exception; + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + private static string Truncate(string value, int maximum) => value.Length <= maximum ? value : value[..maximum]; @@ -247,4 +286,7 @@ private sealed class BrokerResponseException(int? statusCode, Exception? innerEx { internal int? StatusCode { get; } = statusCode; } + + private sealed class BrokerAuthenticationException(Exception innerException) + : Exception("broker server authentication failed", innerException); } diff --git a/package/AgentPolicyConsent/PeerTrust.cs b/package/AgentPolicyConsent/PeerTrust.cs index 846d151d1..502742540 100644 --- a/package/AgentPolicyConsent/PeerTrust.cs +++ b/package/AgentPolicyConsent/PeerTrust.cs @@ -242,7 +242,7 @@ internal static X509Certificate2 VerifyAuthenticodeSigner( Marshal.StructureToPtr(data, dataPointer, false); dataInitialized = true; int status = Native.WinVerifyTrust(new IntPtr(-1), ref action, dataPointer); - if (status != 0) + if (!IsAuthenticodeStatusAccepted(status)) { throw new InvalidOperationException($"{subject} Authenticode validation failed (0x{status:X8})"); } @@ -289,6 +289,8 @@ internal static X509Certificate2 VerifyAuthenticodeSigner( } } + internal static bool IsAuthenticodeStatusAccepted(int status) => status == 0; + private static void VerifySigner(X509Certificate2 certificate) { byte[] subjectPublicKeyInfo; @@ -494,6 +496,11 @@ public void Dispose() internal static partial class Native { + internal const uint WtdRevokeWholeChain = 1; + internal const uint WtdRevocationCheckChain = 0x0000_0040; + internal const uint WtdCacheOnlyUrlRetrieval = 0x0000_1000; + internal const uint WtdDisableMd2Md4 = 0x0000_2000; + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] internal readonly struct WinTrustFileInfo { @@ -535,13 +542,13 @@ internal WinTrustData(IntPtr fileInfo) PolicyCallbackData = IntPtr.Zero; SipClientData = IntPtr.Zero; UiChoice = 2; - RevocationChecks = 0; + RevocationChecks = WtdRevokeWholeChain; UnionChoice = 1; FileInfo = fileInfo; StateAction = 1; StateData = IntPtr.Zero; UrlReference = IntPtr.Zero; - ProviderFlags = 0x1000 | 0x2000; + ProviderFlags = WtdRevocationCheckChain | WtdDisableMd2Md4; UiContext = 0; SignatureSettings = IntPtr.Zero; } From df31e2b31d0a4ce67546988d5267dd6a9ec50041 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Thu, 10 Sep 2026 00:57:16 -0400 Subject: [PATCH 17/39] fix(agent): bind broker trust to running image Authenticate the connected Agent through its retained mapped image and an allowed Devolutions signer before sending policy data. Pin the protected installation directory chain against reparse and untrusted-writer replacement, including custom install locations, and record the evidence behind both UniGetUI signer pins. Issue: #1963 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../AgentPolicyConsent.Tests/ProtocolTests.cs | 44 ++++ package/AgentPolicyConsent/PeerTrust.cs | 236 +++++++++++++++++- .../PolicyConsentContract.cs | 20 ++ 3 files changed, 288 insertions(+), 12 deletions(-) diff --git a/package/AgentPolicyConsent.Tests/ProtocolTests.cs b/package/AgentPolicyConsent.Tests/ProtocolTests.cs index e5e0a21ca..07a87266e 100644 --- a/package/AgentPolicyConsent.Tests/ProtocolTests.cs +++ b/package/AgentPolicyConsent.Tests/ProtocolTests.cs @@ -1,4 +1,5 @@ using System.Buffers.Binary; +using System.Security.AccessControl; using System.Text.Json; using DevolutionsAgentPolicyConsent; using Microsoft.Win32.SafeHandles; @@ -41,6 +42,46 @@ public void LookalikeSignerIsRejected() Assert.False(PeerLease.IsAllowedSigner(new string('0', 64))); } + [Fact] + public void AgentSignerRequiresKnownDevolutionsCertificate() + { + Assert.All( + PolicyConsentContract.DevolutionsSignerSha1Thumbprints, + thumbprint => Assert.True(PeerLease.IsAllowedDevolutionsSigner(thumbprint))); + Assert.False(PeerLease.IsAllowedDevolutionsSigner(new string('0', 40))); + } + + [Theory] + [InlineData("O:SYG:SYD:(A;;FA;;;SY)(A;;FA;;;BA)(A;;0x1200A9;;;BU)", PeerLease.FileTamperRights, true)] + [InlineData("O:BUG:SYD:(A;;FA;;;SY)(A;;FA;;;BA)", PeerLease.FileTamperRights, false)] + [InlineData("O:SYG:SYD:(A;;FA;;;SY)(A;;FA;;;BA)(A;;GW;;;BU)", PeerLease.FileTamperRights, false)] + [InlineData("O:SYG:SYD:(A;;FA;;;SY)(A;;FA;;;BA)(A;;0x6;;;AU)", PeerLease.ParentDirectoryTamperRights, false)] + [InlineData("O:SYG:SYD:(A;;FA;;;SY)(A;;FA;;;BA)(A;;0x6;;;AU)", PeerLease.AncestorDirectoryTamperRights, true)] + [InlineData("O:SYG:SYD:(A;;FA;;;SY)(A;;FA;;;BA)(A;;0x40;;;BU)", PeerLease.AncestorDirectoryTamperRights, false)] + public void ProtectedAgentPathRequiresTrustedOwnerAndWriters( + string sddl, + int tamperRights, + bool accepted) + { + RawSecurityDescriptor descriptor = new(sddl); + if (accepted) + { + PeerLease.VerifyTrustedSecurityDescriptor(descriptor, "test path", tamperRights); + } + else + { + Assert.Throws( + () => PeerLease.VerifyTrustedSecurityDescriptor(descriptor, "test path", tamperRights)); + } + } + + [Fact] + public void ProtectedAgentPathRejectsReparsePoints() + { + Assert.True(PeerLease.IsReparsePoint(PeerLease.FileAttributeReparsePoint)); + Assert.False(PeerLease.IsReparsePoint(0)); + } + [Fact] public void SignerMatchingIsCaseSensitive() { @@ -90,6 +131,9 @@ public void BrokerServerRequiresExactAgentSiblingPath() Assert.False(BrokerServerLease.IsExpectedPath( @"C:\Users\Alice\DevolutionsAgent.exe", @"C:\Program Files\Devolutions\Agent\DevolutionsAgent.exe")); + Assert.True(BrokerServerLease.IsExpectedPath( + @"D:\Managed Apps\Agent\DevolutionsAgent.exe", + @"d:\managed apps\Agent\DevolutionsAgent.exe")); } [Fact] diff --git a/package/AgentPolicyConsent/PeerTrust.cs b/package/AgentPolicyConsent/PeerTrust.cs index 502742540..9fc0eb204 100644 --- a/package/AgentPolicyConsent/PeerTrust.cs +++ b/package/AgentPolicyConsent/PeerTrust.cs @@ -2,8 +2,10 @@ using System.ComponentModel; using System.Diagnostics; using System.Runtime.InteropServices; +using System.Security.AccessControl; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; +using System.Security.Principal; using System.Text; namespace DevolutionsAgentPolicyConsent; @@ -21,8 +23,14 @@ internal sealed class PeerLease : IDisposable internal const uint Synchronize = 0x0010_0000; internal const uint GenericRead = 0x8000_0000; internal const uint FileExecute = 0x20; + internal const uint FileReadAttributes = 0x80; + internal const uint ReadControl = 0x0002_0000; internal const uint FileShareRead = 0x1; + internal const uint FileShareWrite = 0x2; internal const uint OpenExisting = 3; + internal const uint FileAttributeReparsePoint = 0x400; + internal const uint FileFlagBackupSemantics = 0x0200_0000; + internal const uint FileFlagOpenReparsePoint = 0x0020_0000; internal const int ProcessImageFileMapping = 44; internal const uint StillActive = 259; @@ -122,6 +130,10 @@ internal static bool IsAllowedSigner(string digest) => FixedTimeEqualsHex(digest, CurrentUiSignerSpkiSha256) || FixedTimeEqualsHex(digest, TransitionUiSignerSpkiSha256); + internal static bool IsAllowedDevolutionsSigner(string thumbprint) => + PolicyConsentContract.DevolutionsSignerSha1Thumbprints.Any( + expected => FixedTimeEqualsHex(thumbprint, expected, 40)); + internal static bool IsSupportedUiIdentity(string? productName, string? originalFilename, string? productVersion) => string.Equals(productName, "UniGetUI", StringComparison.Ordinal) && string.Equals(originalFilename, "UniGetUI.dll", StringComparison.OrdinalIgnoreCase) && @@ -315,10 +327,10 @@ private static void VerifySigner(X509Certificate2 certificate) } } - private static bool FixedTimeEqualsHex(string candidate, string expected) + private static bool FixedTimeEqualsHex(string candidate, string expected, int length = 64) { - if (candidate.Length != 64 || - expected.Length != 64 || + if (candidate.Length != length || + expected.Length != length || candidate.AsSpan().IndexOfAnyExcept("0123456789abcdef") >= 0) { return false; @@ -350,6 +362,107 @@ internal static void VerifyImageMapping(SafeProcessHandle process, SafeFileHandl } } + internal static void VerifyProtectedPath(SafeFileHandle handle, string subject, int tamperRights) + { + if (!Native.GetFileInformationByHandle(handle, out Native.ByHandleFileInformation information)) + { + throw new Win32Exception(); + } + if (IsReparsePoint(information.FileAttributes)) + { + throw new InvalidOperationException($"{subject} is a reparse point"); + } + + uint error = Native.GetSecurityInfo( + handle, + 1, + 0x1 | 0x4, + out _, + out _, + out _, + out _, + out IntPtr securityDescriptor); + if (error != 0) + { + throw new Win32Exception(checked((int)error)); + } + + try + { + int length = checked((int)Native.GetSecurityDescriptorLength(securityDescriptor)); + byte[] bytes = new byte[length]; + Marshal.Copy(securityDescriptor, bytes, 0, length); + VerifyTrustedSecurityDescriptor(new RawSecurityDescriptor(bytes, 0), subject, tamperRights); + } + finally + { + _ = Native.LocalFree(securityDescriptor); + } + } + + internal static bool IsReparsePoint(uint attributes) => + (attributes & FileAttributeReparsePoint) != 0; + + internal const int FileTamperRights = + 0x0000_0002 | 0x0000_0004 | 0x0000_0010 | 0x0000_0100 | + 0x0001_0000 | 0x0004_0000 | 0x0008_0000 | 0x1000_0000 | 0x4000_0000; + internal const int ParentDirectoryTamperRights = + 0x0000_0002 | 0x0000_0004 | 0x0000_0040 | + 0x0001_0000 | 0x0004_0000 | 0x0008_0000 | 0x1000_0000 | 0x4000_0000; + internal const int AncestorDirectoryTamperRights = + 0x0000_0040 | 0x0001_0000 | 0x0004_0000 | 0x0008_0000 | 0x1000_0000; + + internal static void VerifyTrustedSecurityDescriptor( + RawSecurityDescriptor descriptor, + string subject, + int tamperRights) + { + if (descriptor.Owner is not SecurityIdentifier owner || !IsTrustedWriter(owner)) + { + throw new InvalidOperationException($"{subject} has an untrusted owner"); + } + if (!descriptor.ControlFlags.HasFlag(ControlFlags.DiscretionaryAclPresent) || + descriptor.DiscretionaryAcl is not { Count: > 0 } dacl) + { + throw new InvalidOperationException($"{subject} has no protective DACL"); + } + + foreach (GenericAce generic in dacl) + { + if (generic.AceFlags.HasFlag(AceFlags.InheritOnly) || !IsAccessAllowedAce(generic.AceType)) + { + continue; + } + if (generic is not QualifiedAce ace || ace is not KnownAce known) + { + throw new InvalidOperationException($"{subject} has an unsupported access-allowed entry"); + } + if ((known.AccessMask & tamperRights) == 0) + { + continue; + } + if (ace.SecurityIdentifier is null || !IsTrustedWriter(ace.SecurityIdentifier)) + { + throw new InvalidOperationException($"{subject} grants write access to an untrusted principal"); + } + } + } + + private static bool IsAccessAllowedAce(AceType type) => + type is AceType.AccessAllowed or + AceType.AccessAllowedCompound or + AceType.AccessAllowedObject or + AceType.AccessAllowedCallback or + AceType.AccessAllowedCallbackObject; + + private static bool IsTrustedWriter(SecurityIdentifier sid) => + sid.IsWellKnown(WellKnownSidType.LocalSystemSid) || + sid.IsWellKnown(WellKnownSidType.BuiltinAdministratorsSid) || + string.Equals( + sid.Value, + "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464", + StringComparison.Ordinal); + internal static string ImagePath(SafeProcessHandle process) { int capacity = 260; @@ -407,11 +520,16 @@ internal sealed class BrokerServerLease : IDisposable private readonly SafeProcessHandle process; private readonly SafeFileHandle image; + private readonly List directories; - private BrokerServerLease(SafeProcessHandle process, SafeFileHandle image) + private BrokerServerLease( + SafeProcessHandle process, + SafeFileHandle image, + List directories) { this.process = process; this.image = image; + this.directories = directories; } internal static BrokerServerLease Open(SafePipeHandle pipe) @@ -422,7 +540,9 @@ internal static BrokerServerLease Open(SafePipeHandle pipe) } SafeProcessHandle process = Native.OpenProcess( - PeerLease.ProcessQueryLimitedInformation | PeerLease.Synchronize, + PeerLease.ProcessQueryInformation | + PeerLease.ProcessQueryLimitedInformation | + PeerLease.Synchronize, false, processId); if (process.IsInvalid) @@ -449,12 +569,15 @@ internal static BrokerServerLease Open(SafePipeHandle pipe) } SafeFileHandle image = Native.CreateFile( - expectedPath, - PeerLease.GenericRead | PeerLease.FileExecute | PeerLease.Synchronize, + serverPath, + PeerLease.GenericRead | + PeerLease.FileExecute | + PeerLease.ReadControl | + PeerLease.Synchronize, PeerLease.FileShareRead, IntPtr.Zero, PeerLease.OpenExisting, - 0, + PeerLease.FileFlagOpenReparsePoint, IntPtr.Zero); if (image.IsInvalid) { @@ -463,10 +586,37 @@ internal static BrokerServerLease Open(SafePipeHandle pipe) try { - using X509Certificate2 _ = - PeerLease.VerifyAuthenticodeSigner(expectedPath, image, "broker server"); - PeerLease.EnsureActive(process); - return new BrokerServerLease(process, image); + PeerLease.VerifyImageMapping(process, image); + PeerLease.VerifyProtectedPath(image, "broker server image", PeerLease.FileTamperRights); + using X509Certificate2 signer = + PeerLease.VerifyAuthenticodeSigner(serverPath, image, "broker server"); + string thumbprint = signer.GetCertHashString(HashAlgorithmName.SHA1).ToLowerInvariant(); + if (!PeerLease.IsAllowedDevolutionsSigner(thumbprint)) + { + throw new InvalidOperationException("broker server signer is not authorized"); + } + List? directories = RetainProtectedDirectories( + Path.GetDirectoryName(expectedPath) + ?? throw new InvalidOperationException("Agent installation directory is unavailable")); + try + { + PeerLease.VerifyImageMapping(process, image); + PeerLease.EnsureActive(process); + if (!Native.GetNamedPipeServerProcessId(pipe, out int confirmedProcessId) || + confirmedProcessId != processId) + { + throw new InvalidOperationException("broker server process changed during authentication"); + } + return new BrokerServerLease(process, image, directories); + } + catch + { + foreach (SafeFileHandle directory in directories) + { + directory.Dispose(); + } + throw; + } } catch { @@ -489,9 +639,54 @@ internal static bool IsExpectedPath(string actual, string expected) => public void Dispose() { + foreach (SafeFileHandle directory in directories) + { + directory.Dispose(); + } image.Dispose(); process.Dispose(); } + + private static List RetainProtectedDirectories(string installationDirectory) + { + List handles = []; + try + { + int tamperRights = PeerLease.ParentDirectoryTamperRights; + for (DirectoryInfo? directory = new(Path.GetFullPath(installationDirectory)); + directory is not null; + directory = directory.Parent) + { + SafeFileHandle handle = Native.CreateFile( + directory.FullName, + PeerLease.FileReadAttributes | PeerLease.ReadControl | PeerLease.Synchronize, + PeerLease.FileShareRead | PeerLease.FileShareWrite, + IntPtr.Zero, + PeerLease.OpenExisting, + PeerLease.FileFlagBackupSemantics | PeerLease.FileFlagOpenReparsePoint, + IntPtr.Zero); + if (handle.IsInvalid) + { + throw new Win32Exception(); + } + handles.Add(handle); + PeerLease.VerifyProtectedPath( + handle, + $"Agent installation directory '{directory.FullName}'", + tamperRights); + tamperRights = PeerLease.AncestorDirectoryTamperRights; + } + return handles; + } + catch + { + foreach (SafeFileHandle handle in handles) + { + handle.Dispose(); + } + throw; + } + } } internal static partial class Native @@ -649,6 +844,23 @@ internal static partial bool GetTokenInformation( [return: MarshalAs(UnmanagedType.Bool)] internal static partial bool IsWellKnownSid(IntPtr sid, int wellKnownSidType); + [LibraryImport("advapi32.dll", SetLastError = true)] + internal static partial uint GetSecurityInfo( + SafeFileHandle handle, + uint objectType, + uint securityInformation, + out IntPtr owner, + out IntPtr group, + out IntPtr dacl, + out IntPtr sacl, + out IntPtr securityDescriptor); + + [LibraryImport("advapi32.dll")] + internal static partial uint GetSecurityDescriptorLength(IntPtr securityDescriptor); + + [LibraryImport("kernel32.dll")] + internal static partial IntPtr LocalFree(IntPtr memory); + [LibraryImport("kernel32.dll", EntryPoint = "CreateFileW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] internal static partial SafeFileHandle CreateFile( string fileName, diff --git a/package/AgentPolicyConsent/PolicyConsentContract.cs b/package/AgentPolicyConsent/PolicyConsentContract.cs index 01789bbf5..697d606d3 100644 --- a/package/AgentPolicyConsent/PolicyConsentContract.cs +++ b/package/AgentPolicyConsent/PolicyConsentContract.cs @@ -5,9 +5,29 @@ internal static class PolicyConsentContract internal const string ProtocolVersion = "2.0"; internal const string ExecutableName = "DevolutionsAgentPolicyConsent.exe"; internal const string ProductName = "Devolutions Agent Policy Consent"; + + // UniGetUI 2026.2.7: subject CN=Devolutions Inc, O=Devolutions Inc, C=CA; + // issuer CN=GlobalSign GCC R45 EV CodeSigning CA 2020, O=GlobalSign nv-sa, C=BE; + // serial 73D3C33603FF8BB44224F25E, SHA-1 8DB5A43BB8AFE4D2FFB92DA9007D8997A4CC4E13, + // valid 2023-10-30T17:51:18Z through 2026-10-30T17:51:18Z. internal const string CurrentUiSignerSpkiSha256 = "e43ed3368eaabff61abc79eb338cba9da88a80d93b751735ff417f26afa579a8"; + + // UniGetUI 3.3.7: subject CN="Open Source Developer, Martí Climent López", + // O=Open Source Developer, C=ES; issuer CN=Certum Code Signing 2021 CA, + // O=Asseco Data Systems S.A., C=PL; + // serial 1AC2CAA58AF100E402D9812002C08B30, SHA-1 28949703053434989162B12C101497DE35FE4E8E, + // valid 2025-06-24T18:02:38Z through 2026-06-24T18:02:37Z. + // Remove this transition pin when the minimum supported UniGetUI version postdates its last signed release. internal const string TransitionUiSignerSpkiSha256 = "99e7adb5894e242d87d32b8ad6cb5a1e0d2dd791a447bd7192c30189ef083fab"; + + // Keep synchronized with devolutions-agent-shared/src/windows/code_signing.rs. + internal static readonly string[] DevolutionsSignerSha1Thumbprints = + [ + "3f5202a9432d54293bdfe6f7e46adb0a6f8b3ba6", + "8db5a43bb8afe4d2ffb92da9007d8997a4cc4e13", + "50f753333811ff11f1920274afde3ffd4468b210", + ]; } } From ff025b1ebc35e6773385a77366e512fddc9ede51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Thu, 10 Sep 2026 01:52:23 -0400 Subject: [PATCH 18/39] fix(agent,agent-installer): correct helper packaging Preserve timeout responses during broker authentication, accept prerelease UniGetUI versions, and suppress the helper console window. Mark ARM64 discovery registry components as 64-bit so WiX can package them beneath ProgramFiles64Folder. Issue: #1963 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- package/AgentPolicyConsent.Tests/ProtocolTests.cs | 1 + package/AgentPolicyConsent/BrokerClient.cs | 5 +++++ .../AgentPolicyConsent/DevolutionsAgentPolicyConsent.csproj | 2 +- package/AgentPolicyConsent/PeerTrust.cs | 2 +- package/AgentWindowsManaged/Program.cs | 2 +- 5 files changed, 9 insertions(+), 3 deletions(-) diff --git a/package/AgentPolicyConsent.Tests/ProtocolTests.cs b/package/AgentPolicyConsent.Tests/ProtocolTests.cs index 07a87266e..f3bdbb5ff 100644 --- a/package/AgentPolicyConsent.Tests/ProtocolTests.cs +++ b/package/AgentPolicyConsent.Tests/ProtocolTests.cs @@ -91,6 +91,7 @@ public void SignerMatchingIsCaseSensitive() [Theory] [InlineData("3.3.7")] [InlineData("2026.2.7")] + [InlineData("2026.2.7-preview")] public void ProductBindingSupportsProtocolEraAndCurrentInstallModes(string version) { Assert.True(PeerLease.IsSupportedUiIdentity("UniGetUI", "UniGetUI.dll", version)); diff --git a/package/AgentPolicyConsent/BrokerClient.cs b/package/AgentPolicyConsent/BrokerClient.cs index 4a1d5affc..1dfe80305 100644 --- a/package/AgentPolicyConsent/BrokerClient.cs +++ b/package/AgentPolicyConsent/BrokerClient.cs @@ -255,6 +255,11 @@ private static async Task OpenBrokerServerAsync( { return await open.WaitAsync(cancellationToken); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + DisposeLateResult(open); + throw; + } catch (Exception error) { DisposeLateResult(open); diff --git a/package/AgentPolicyConsent/DevolutionsAgentPolicyConsent.csproj b/package/AgentPolicyConsent/DevolutionsAgentPolicyConsent.csproj index 5e5e414e4..a8257d4dc 100644 --- a/package/AgentPolicyConsent/DevolutionsAgentPolicyConsent.csproj +++ b/package/AgentPolicyConsent/DevolutionsAgentPolicyConsent.csproj @@ -1,6 +1,6 @@ - Exe + WinExe net10.0-windows win-x64 true diff --git a/package/AgentPolicyConsent/PeerTrust.cs b/package/AgentPolicyConsent/PeerTrust.cs index 9fc0eb204..2948e109e 100644 --- a/package/AgentPolicyConsent/PeerTrust.cs +++ b/package/AgentPolicyConsent/PeerTrust.cs @@ -138,7 +138,7 @@ internal static bool IsSupportedUiIdentity(string? productName, string? original string.Equals(productName, "UniGetUI", StringComparison.Ordinal) && string.Equals(originalFilename, "UniGetUI.dll", StringComparison.OrdinalIgnoreCase) && productVersion is not null && - Version.TryParse(productVersion.Split('+', '-', StringSplitOptions.TrimEntries)[0], out Version? parsed) && + Version.TryParse(productVersion.Split(['+', '-'], StringSplitOptions.TrimEntries)[0], out Version? parsed) && parsed >= new Version(3, 3, 7); internal static bool MatchesProcessIdentity( diff --git a/package/AgentWindowsManaged/Program.cs b/package/AgentWindowsManaged/Program.cs index 021d3cfb9..970b8dc87 100644 --- a/package/AgentWindowsManaged/Program.cs +++ b/package/AgentWindowsManaged/Program.cs @@ -483,7 +483,7 @@ internal static RegValue CreatePolicyConsentRegistryValue(string name, string va name, value) { - AttributesDefinition = "Type=string", + AttributesDefinition = win64 ? "Type=string; Component:Win64=yes" : "Type=string", Win64 = win64, RegistryKeyAction = RegistryKeyAction.createAndRemoveOnUninstall, Feature = Features.AGENT_FEATURE, From 357d19d6c1e8d00c28e552e0d385291f2b3a8e4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Thu, 10 Sep 2026 02:04:59 -0400 Subject: [PATCH 19/39] fix(agent): validate policy credentials Apply the canonical policy API safe-ASCII character set to helper credentials before a privileged request is dispatched. Issue: #1963 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../AgentPolicyConsent.Tests/ProtocolTests.cs | 18 +++++++++++++++--- package/AgentPolicyConsent/Protocol.cs | 2 +- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/package/AgentPolicyConsent.Tests/ProtocolTests.cs b/package/AgentPolicyConsent.Tests/ProtocolTests.cs index f3bdbb5ff..9b4aafaee 100644 --- a/package/AgentPolicyConsent.Tests/ProtocolTests.cs +++ b/package/AgentPolicyConsent.Tests/ProtocolTests.cs @@ -356,6 +356,18 @@ public void RequestRejectsUnknownOperations(string operation, string conflictHan Assert.Throws(() => Protocol.ValidateRequest(request)); } + [Theory] + [InlineData("a", true)] + [InlineData("A0._~:-", true)] + [InlineData("a/b", false)] + [InlineData("a b", false)] + [InlineData("a\"b", false)] + [InlineData("-token", false)] + public void CredentialsMatchOfficialPolicyApiCharacterRules(string value, bool accepted) + { + Assert.Equal(accepted, Protocol.IsCredential(value, 512)); + } + [Fact] public void CommittedResponseContainsOnlyStoreToken() { @@ -424,7 +436,7 @@ public void ResponseRequiresExplicitNullableMembers() } [Fact] - public void MaximumStaleResponseFitsExactWireBudget() + public void MaximumValidStaleResponseFitsWireBudget() { ElevationResponse response = new( "2.0", @@ -433,9 +445,9 @@ public void MaximumStaleResponseFitsExactWireBudget() 409, "StalePolicyStoreToken", null, - "T" + new string('"', 511), + "T" + new string('~', 511), "Active", - "P" + new string('"', 2047)); + "P" + new string('~', 2047)); Protocol.ValidateResponse(response); byte[] body = JsonSerializer.SerializeToUtf8Bytes( diff --git a/package/AgentPolicyConsent/Protocol.cs b/package/AgentPolicyConsent/Protocol.cs index 04184f3dc..a9057db90 100644 --- a/package/AgentPolicyConsent/Protocol.cs +++ b/package/AgentPolicyConsent/Protocol.cs @@ -172,7 +172,7 @@ value is not null && value.Length is > 0 && value.Length <= maximum && IsAsciiAlphaNumeric(value[0]) && - value.AsSpan(1).IndexOfAnyExceptInRange('!', '~') < 0; + value.AsSpan(1).IndexOfAnyExcept("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._~:-") < 0; private static bool IsOptionalCredential(string? value, int maximum) => value is null || IsCredential(value, maximum); From acc395029a008bb8d81fd208c44c1c5bc9798d9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Thu, 17 Sep 2026 10:02:23 +0900 Subject: [PATCH 20/39] fix(agent,agent-installer): require current UI signer Accept only current-signed UniGetUI hosts from version 2026.2.7. Remove transition signer discovery while retaining protected helper authorization and ARM64 installer coverage. Issue: #1963 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../AgentPolicyConsent.Tests/ProtocolTests.cs | 9 +++-- package/AgentPolicyConsent/PeerTrust.cs | 8 +--- .../PolicyConsentContract.cs | 12 +----- .../PolicyConsentDiscoveryTests.cs | 37 +++++++++++++++++-- 4 files changed, 42 insertions(+), 24 deletions(-) diff --git a/package/AgentPolicyConsent.Tests/ProtocolTests.cs b/package/AgentPolicyConsent.Tests/ProtocolTests.cs index 9b4aafaee..e1d0a0a4d 100644 --- a/package/AgentPolicyConsent.Tests/ProtocolTests.cs +++ b/package/AgentPolicyConsent.Tests/ProtocolTests.cs @@ -30,10 +30,11 @@ public void ArgumentsRequireExactBoundIdentity() public sealed class TrustPolicyTests { [Fact] - public void CurrentAndTransitionSignersAreAccepted() + public void OnlyTheCurrentSignerIsAccepted() { Assert.True(PeerLease.IsAllowedSigner(PeerLease.CurrentUiSignerSpkiSha256)); - Assert.True(PeerLease.IsAllowedSigner(PeerLease.TransitionUiSignerSpkiSha256)); + Assert.False(PeerLease.IsAllowedSigner( + "99e7adb5894e242d87d32b8ad6cb5a1e0d2dd791a447bd7192c30189ef083fab")); } [Fact] @@ -89,10 +90,9 @@ public void SignerMatchingIsCaseSensitive() } [Theory] - [InlineData("3.3.7")] [InlineData("2026.2.7")] [InlineData("2026.2.7-preview")] - public void ProductBindingSupportsProtocolEraAndCurrentInstallModes(string version) + public void ProductBindingSupportsCurrentSignedHosts(string version) { Assert.True(PeerLease.IsSupportedUiIdentity("UniGetUI", "UniGetUI.dll", version)); } @@ -101,6 +101,7 @@ public void ProductBindingSupportsProtocolEraAndCurrentInstallModes(string versi [InlineData("Lookalike", "UniGetUI.dll", "2026.2.7")] [InlineData("UniGetUI", "malware.exe", "2026.2.7")] [InlineData("UniGetUI", "UniGetUI.dll", "3.3.6")] + [InlineData("UniGetUI", "UniGetUI.dll", "2026.2.6")] public void ProductBindingRejectsLookalikes(string product, string originalFilename, string version) { Assert.False(PeerLease.IsSupportedUiIdentity(product, originalFilename, version)); diff --git a/package/AgentPolicyConsent/PeerTrust.cs b/package/AgentPolicyConsent/PeerTrust.cs index 2948e109e..812652fae 100644 --- a/package/AgentPolicyConsent/PeerTrust.cs +++ b/package/AgentPolicyConsent/PeerTrust.cs @@ -12,9 +12,6 @@ namespace DevolutionsAgentPolicyConsent; internal sealed class PeerLease : IDisposable { - // SHA-256 digests of accepted UniGetUI signer SPKIs. Keep both keys during certificate rollover. - internal const string TransitionUiSignerSpkiSha256 = - PolicyConsentContract.TransitionUiSignerSpkiSha256; internal const string CurrentUiSignerSpkiSha256 = PolicyConsentContract.CurrentUiSignerSpkiSha256; @@ -127,8 +124,7 @@ internal void VerifyConnectedServer(int serverProcessId) } internal static bool IsAllowedSigner(string digest) => - FixedTimeEqualsHex(digest, CurrentUiSignerSpkiSha256) || - FixedTimeEqualsHex(digest, TransitionUiSignerSpkiSha256); + FixedTimeEqualsHex(digest, CurrentUiSignerSpkiSha256); internal static bool IsAllowedDevolutionsSigner(string thumbprint) => PolicyConsentContract.DevolutionsSignerSha1Thumbprints.Any( @@ -139,7 +135,7 @@ internal static bool IsSupportedUiIdentity(string? productName, string? original string.Equals(originalFilename, "UniGetUI.dll", StringComparison.OrdinalIgnoreCase) && productVersion is not null && Version.TryParse(productVersion.Split(['+', '-'], StringSplitOptions.TrimEntries)[0], out Version? parsed) && - parsed >= new Version(3, 3, 7); + parsed >= new Version(2026, 2, 7); internal static bool MatchesProcessIdentity( Arguments expected, diff --git a/package/AgentPolicyConsent/PolicyConsentContract.cs b/package/AgentPolicyConsent/PolicyConsentContract.cs index 697d606d3..d4d0958cd 100644 --- a/package/AgentPolicyConsent/PolicyConsentContract.cs +++ b/package/AgentPolicyConsent/PolicyConsentContract.cs @@ -6,22 +6,14 @@ internal static class PolicyConsentContract internal const string ExecutableName = "DevolutionsAgentPolicyConsent.exe"; internal const string ProductName = "Devolutions Agent Policy Consent"; - // UniGetUI 2026.2.7: subject CN=Devolutions Inc, O=Devolutions Inc, C=CA; + // Supported UniGetUI hosts are version 2026.2.7 or newer and must carry this signer: + // subject CN=Devolutions Inc, O=Devolutions Inc, C=CA; // issuer CN=GlobalSign GCC R45 EV CodeSigning CA 2020, O=GlobalSign nv-sa, C=BE; // serial 73D3C33603FF8BB44224F25E, SHA-1 8DB5A43BB8AFE4D2FFB92DA9007D8997A4CC4E13, // valid 2023-10-30T17:51:18Z through 2026-10-30T17:51:18Z. internal const string CurrentUiSignerSpkiSha256 = "e43ed3368eaabff61abc79eb338cba9da88a80d93b751735ff417f26afa579a8"; - // UniGetUI 3.3.7: subject CN="Open Source Developer, Martí Climent López", - // O=Open Source Developer, C=ES; issuer CN=Certum Code Signing 2021 CA, - // O=Asseco Data Systems S.A., C=PL; - // serial 1AC2CAA58AF100E402D9812002C08B30, SHA-1 28949703053434989162B12C101497DE35FE4E8E, - // valid 2025-06-24T18:02:38Z through 2026-06-24T18:02:37Z. - // Remove this transition pin when the minimum supported UniGetUI version postdates its last signed release. - internal const string TransitionUiSignerSpkiSha256 = - "99e7adb5894e242d87d32b8ad6cb5a1e0d2dd791a447bd7192c30189ef083fab"; - // Keep synchronized with devolutions-agent-shared/src/windows/code_signing.rs. internal static readonly string[] DevolutionsSignerSha1Thumbprints = [ diff --git a/package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs b/package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs index a17e10bef..83a7a1210 100644 --- a/package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs +++ b/package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs @@ -20,23 +20,52 @@ public void DiscoveryIsTransactionalAndArchitectureCorrect(bool win64) Assert.Equal(@"Software\Devolutions\Agent\PolicyConsentHelper", value.Key); Assert.Equal(RegistryKeyAction.createAndRemoveOnUninstall, value.RegistryKeyAction); Assert.Equal(win64, value.Win64); + Assert.Equal( + win64 ? "Type=string; Component:Win64=yes" : "Type=string", + value.AttributesDefinition); + } + + [Theory] + [InlineData(Platform.x86, false)] + [InlineData(Platform.x64, true)] + [InlineData(Platform.arm64, true)] + public void DiscoveryUsesTheConsumerNativeRegistryView(Platform platform, bool expected) + { + Type program = System.Reflection.Assembly + .Load("DevolutionsAgent") + .GetType("DevolutionsAgent.Program", throwOnError: true); + MethodInfo method = program.GetMethod( + "Use64BitRegistryView", + BindingFlags.Static | BindingFlags.NonPublic); + + Assert.Equal(expected, Assert.IsType(method.Invoke(null, [platform]))); } [Fact] public void DiscoveryPublishesTheFixedHelperIdentity() { - RegValue value = CreateDiscoveryValue( + RegValue executablePath = CreateDiscoveryValue( "ExecutablePath", "[INSTALLDIR]DevolutionsAgentPolicyConsent.exe", true); + RegValue signer = CreateDiscoveryValue( + "CurrentUiSignerSpkiSha256", + "e43ed3368eaabff61abc79eb338cba9da88a80d93b751735ff417f26afa579a8", + true); - Assert.Equal("[INSTALLDIR]DevolutionsAgentPolicyConsent.exe", value.Value); - Assert.Equal(RegistryKeyAction.createAndRemoveOnUninstall, value.RegistryKeyAction); + Assert.Equal("[INSTALLDIR]DevolutionsAgentPolicyConsent.exe", executablePath.Value); + Assert.Equal( + "e43ed3368eaabff61abc79eb338cba9da88a80d93b751735ff417f26afa579a8", + signer.Value); + Assert.Equal(RegistryKeyAction.createAndRemoveOnUninstall, executablePath.RegistryKeyAction); + Assert.Equal(RegistryKeyAction.createAndRemoveOnUninstall, signer.RegistryKeyAction); } private static RegValue CreateDiscoveryValue(string name, string value, bool win64) { - Type program = Assembly.Load("DevolutionsAgent").GetType("DevolutionsAgent.Program", throwOnError: true); + Type program = System.Reflection.Assembly + .Load("DevolutionsAgent") + .GetType("DevolutionsAgent.Program", throwOnError: true); MethodInfo method = program.GetMethod( "CreatePolicyConsentRegistryValue", BindingFlags.Static | BindingFlags.NonPublic); From f1b2e20e7fe433c0e00c3a6de67588d32723e99e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 01:36:11 +0900 Subject: [PATCH 21/39] fix(agent,agent-installer): discover broker pipe Publish the configured broker pipe through the protected helper discovery key so consent writes work with custom local pipe names. Reject malformed discovery and oversized requests before dispatch. Issue: #1963 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 1 + devolutions-agent/Cargo.toml | 1 + devolutions-agent/src/service.rs | 63 +++++++++++++++++-- .../AgentPolicyConsent.Tests/ProtocolTests.cs | 50 +++++++++++++++ package/AgentPolicyConsent/BrokerClient.cs | 49 +++++++++++++-- .../PolicyConsentContract.cs | 1 + package/AgentPolicyConsent/Protocol.cs | 6 +- .../PolicyConsentDiscoveryTests.cs | 6 ++ package/AgentWindowsManaged/Program.cs | 4 ++ .../AgentWindowsManaged/Resources/Includes.cs | 3 + 10 files changed, 173 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2be74766a..661527ab8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1791,6 +1791,7 @@ dependencies = [ "uuid", "win-api-wrappers", "windows 0.61.3", + "windows-registry 0.5.3", "x509-parser", ] diff --git a/devolutions-agent/Cargo.toml b/devolutions-agent/Cargo.toml index bf7ed414e..2dddeda74 100644 --- a/devolutions-agent/Cargo.toml +++ b/devolutions-agent/Cargo.toml @@ -93,6 +93,7 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls- thiserror = "2" uuid = { version = "1.17", features = ["v4"] } win-api-wrappers = { path = "../crates/win-api-wrappers" } +windows-registry = "0.5" [target.'cfg(windows)'.dependencies.windows] version = "0.61" diff --git a/devolutions-agent/src/service.rs b/devolutions-agent/src/service.rs index 6739ea391..46a59f4fe 100644 --- a/devolutions-agent/src/service.rs +++ b/devolutions-agent/src/service.rs @@ -21,10 +21,16 @@ use now_package_broker::pipe::DEFAULT_PIPE_NAME; use now_package_broker::task::{BrokerTask, BrokerTaskConfig}; use tokio::runtime::{self, Runtime}; use tokio::sync::mpsc; +#[cfg(windows)] +use windows_registry::{Key, LOCAL_MACHINE}; pub(crate) const SERVICE_NAME: &str = "devolutions-agent"; pub(crate) const DISPLAY_NAME: &str = "Devolutions Agent"; pub(crate) const DESCRIPTION: &str = "Devolutions Agent service"; +#[cfg(windows)] +const POLICY_CONSENT_DISCOVERY_KEY: &str = r"SOFTWARE\Devolutions\Agent\PolicyConsentHelper"; +#[cfg(windows)] +const POLICY_CONSENT_BROKER_PIPE_NAME_VALUE: &str = "BrokerPipeName"; struct TasksCtx { /// Spawned service tasks @@ -226,12 +232,19 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { ); } + let pipe_name = conf + .package_broker + .pipe_name + .clone() + .unwrap_or_else(|| DEFAULT_PIPE_NAME.to_owned()); + if let Err(error) = publish_policy_consent_broker_pipe_name(&pipe_name) { + warn!( + error = format!("{error:#}"), + "Policy consent helper cannot discover the configured broker pipe" + ); + } let broker_config = BrokerTaskConfig { - pipe_name: conf - .package_broker - .pipe_name - .clone() - .unwrap_or_else(|| DEFAULT_PIPE_NAME.to_owned()), + pipe_name, policy_path: conf.package_broker.policy_path.clone(), // The bypass only takes effect in builds with the development-only // `dev-skip-broker-signature` cargo feature, never in shipped builds. @@ -271,3 +284,43 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { service_event_tx, }) } + +#[cfg(windows)] +fn publish_policy_consent_broker_pipe_name(pipe_name: &str) -> anyhow::Result<()> { + let key = LOCAL_MACHINE + .options() + .read() + .write() + .open(POLICY_CONSENT_DISCOVERY_KEY) + .context("open policy consent helper discovery key")?; + publish_policy_consent_broker_pipe_name_to(&key, pipe_name) +} + +#[cfg(windows)] +fn publish_policy_consent_broker_pipe_name_to(key: &Key, pipe_name: &str) -> anyhow::Result<()> { + key.set_string(POLICY_CONSENT_BROKER_PIPE_NAME_VALUE, pipe_name) + .context("publish configured policy consent helper broker pipe") +} + +#[cfg(all(test, windows))] +mod tests { + use windows_registry::CURRENT_USER; + + use super::*; + + #[test] + fn configured_broker_pipe_is_published_to_writable_discovery_key() { + let path = format!(r"Software\Devolutions\Agent\Tests\{}", uuid::Uuid::new_v4().as_simple()); + let key = CURRENT_USER.create(&path).expect("create temporary discovery key"); + let pipe_name = r"\\.\pipe\custom-broker"; + + publish_policy_consent_broker_pipe_name_to(&key, pipe_name).expect("publish configured broker pipe"); + + assert_eq!( + key.get_string(POLICY_CONSENT_BROKER_PIPE_NAME_VALUE) + .expect("read published broker pipe"), + pipe_name + ); + CURRENT_USER.remove_tree(&path).expect("remove temporary discovery key"); + } +} diff --git a/package/AgentPolicyConsent.Tests/ProtocolTests.cs b/package/AgentPolicyConsent.Tests/ProtocolTests.cs index e1d0a0a4d..bbb7bbb5d 100644 --- a/package/AgentPolicyConsent.Tests/ProtocolTests.cs +++ b/package/AgentPolicyConsent.Tests/ProtocolTests.cs @@ -338,6 +338,56 @@ public void RequestRequiresEveryMemberAndObjectDraft() Assert.Throws(() => Protocol.ValidateRequest(request)); } + [Fact] + public void RequestRejectsExplicitNullRequiredMembers() + { + string nullRequestId = + """{"protocolVersion":"2.0","requestId":null,"operation":"Update","conflictHandling":"Reject","expectedStoreToken":"a","validationReceipt":"b","warningsAcknowledged":false,"draft":{}}"""; + + ElevationRequest request = JsonSerializer.Deserialize( + nullRequestId, + ProtocolJsonContext.Default.ElevationRequest) + ?? throw new InvalidOperationException("request should deserialize"); + Assert.Throws(() => Protocol.ValidateRequest(request)); + } + + [Theory] + [InlineData(@"\\.\pipe\Devolutions.Now.PackageBroker.v1", "Devolutions.Now.PackageBroker.v1")] + [InlineData("custom-broker", "custom-broker")] + public void BrokerPipeDiscoveryNormalizesLocalPipeNames(string configured, string expected) + { + Assert.Equal(expected, BrokerClient.NormalizeBrokerPipeName(configured)); + } + + [Theory] + [InlineData("")] + [InlineData(@"\\server\pipe\broker")] + [InlineData(@"\\.\pipe\")] + public void BrokerPipeDiscoveryRejectsNonlocalOrEmptyNames(string configured) + { + Assert.ThrowsAny(() => BrokerClient.NormalizeBrokerPipeName(configured)); + } + + [Fact] + public async Task OversizedOfficialRequestIsRejectedBeforeBrokerConnection() + { + using JsonDocument draft = JsonDocument.Parse($$"""{"padding":"{{new string('x', Protocol.MaxRequestBodyBytes)}}" }"""); + ElevationRequest request = new( + "2.0", + RequestId, + "Update", + "Reject", + "token", + "receipt", + false, + draft.RootElement.Clone()); + + ElevationResponse response = await BrokerClient.ReplaceAsync(request, CancellationToken.None); + + Assert.Equal("Rejected", response.Disposition); + Assert.Equal("InvalidRequest", response.BrokerErrorCode); + } + [Theory] [InlineData("Delete", "Reject")] [InlineData("Update", "Overwrite")] diff --git a/package/AgentPolicyConsent/BrokerClient.cs b/package/AgentPolicyConsent/BrokerClient.cs index 1dfe80305..c2594e4b0 100644 --- a/package/AgentPolicyConsent/BrokerClient.cs +++ b/package/AgentPolicyConsent/BrokerClient.cs @@ -1,5 +1,6 @@ using System.Buffers; using System.IO.Pipes; +using Microsoft.Win32; using System.Text; using System.Text.Json; @@ -7,7 +8,8 @@ namespace DevolutionsAgentPolicyConsent; internal static class BrokerClient { - private const string PipeName = "Devolutions.Now.PackageBroker.v1"; + private const string BrokerPipeNameValue = "BrokerPipeName"; + private const string DiscoveryKey = @"SOFTWARE\Devolutions\Agent\PolicyConsentHelper"; private const int MaximumHeaderBytes = 64 * 1024; private const int MaximumBrokerResponseBytes = 50_606_928; private static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(5); @@ -16,12 +18,12 @@ internal static async Task ReplaceAsync( ElevationRequest request, CancellationToken cancellationToken) { - byte[] body = CreateOfficialRequest(request); try { + byte[] body = CreateOfficialRequest(request); using NamedPipeClientStream pipe = new( ".", - PipeName, + ReadBrokerPipeName(), PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.WriteThrough, System.Security.Principal.TokenImpersonationLevel.Anonymous); @@ -64,14 +66,34 @@ internal static async Task ReplaceAsync( { return Rejected(request.RequestId, "Unauthorized"); } + catch (BrokerDiscoveryException) + { + return Rejected(request.RequestId, "BrokerUnavailable"); + } catch (InvalidOperationException) { return Unknown(request.RequestId, null, "InvalidResponse"); } catch (ProtocolException) { - return Unknown(request.RequestId, null, "InvalidResponse"); + return Rejected(request.RequestId, "InvalidRequest"); + } + } + + internal static string NormalizeBrokerPipeName(string pipeName) + { + const string LocalPipePrefix = @"\\.\pipe\"; + if (pipeName.StartsWith(LocalPipePrefix, StringComparison.OrdinalIgnoreCase)) + { + pipeName = pipeName[LocalPipePrefix.Length..]; + } + if (pipeName.Length is 0 or > 256 || + pipeName.IndexOf('\0') >= 0 || + pipeName.Contains('\\')) + { + throw new BrokerDiscoveryException(); } + return pipeName; } internal static byte[] CreateOfficialRequest(ElevationRequest request) @@ -246,6 +268,17 @@ private static ElevationResponse Unknown(string requestId, int? statusCode, stri private static ElevationResponse Rejected(string requestId, string errorCode) => new(Protocol.Version, requestId, "Rejected", null, errorCode, null, null, null, null); + private static string ReadBrokerPipeName() + { + using RegistryKey machine = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64); + using RegistryKey? discovery = machine.OpenSubKey(DiscoveryKey); + if (discovery?.GetValue(BrokerPipeNameValue) is not string pipeName) + { + throw new BrokerDiscoveryException(); + } + return NormalizeBrokerPipeName(pipeName); + } + private static async Task OpenBrokerServerAsync( NamedPipeClientStream pipe, CancellationToken cancellationToken) @@ -294,4 +327,12 @@ private sealed class BrokerResponseException(int? statusCode, Exception? innerEx private sealed class BrokerAuthenticationException(Exception innerException) : Exception("broker server authentication failed", innerException); + + private sealed class BrokerDiscoveryException : InvalidOperationException + { + internal BrokerDiscoveryException() + : base("broker pipe discovery is unavailable") + { + } + } } diff --git a/package/AgentPolicyConsent/PolicyConsentContract.cs b/package/AgentPolicyConsent/PolicyConsentContract.cs index d4d0958cd..5cba60549 100644 --- a/package/AgentPolicyConsent/PolicyConsentContract.cs +++ b/package/AgentPolicyConsent/PolicyConsentContract.cs @@ -5,6 +5,7 @@ internal static class PolicyConsentContract internal const string ProtocolVersion = "2.0"; internal const string ExecutableName = "DevolutionsAgentPolicyConsent.exe"; internal const string ProductName = "Devolutions Agent Policy Consent"; + internal const string DefaultBrokerPipeName = @"\\.\pipe\Devolutions.Now.PackageBroker.v1"; // Supported UniGetUI hosts are version 2026.2.7 or newer and must carry this signer: // subject CN=Devolutions Inc, O=Devolutions Inc, C=CA; diff --git a/package/AgentPolicyConsent/Protocol.cs b/package/AgentPolicyConsent/Protocol.cs index a9057db90..dcf07ed21 100644 --- a/package/AgentPolicyConsent/Protocol.cs +++ b/package/AgentPolicyConsent/Protocol.cs @@ -63,7 +63,8 @@ internal static Arguments ParseArguments(string[] args) internal static void ValidateRequest(ElevationRequest request) { - if (request.ProtocolVersion != Version || + if (request.RequestId is null || + request.ProtocolVersion != Version || !IsLowerHex(request.RequestId.AsSpan(), 32) || request.Operation is not ("Update" or "ReplaceIdentity" or "Create" or "Repair") || request.ConflictHandling is not ("Reject" or "ConfirmOverwrite") || @@ -77,7 +78,8 @@ request.ConflictHandling is not ("Reject" or "ConfirmOverwrite") || internal static void ValidateResponse(ElevationResponse response) { - if (response.ProtocolVersion != Version || + if (response.RequestId is null || + response.ProtocolVersion != Version || !IsLowerHex(response.RequestId.AsSpan(), 32) || response.Disposition is not ("Committed" or "Rejected" or "Unknown") || !IsOptionalCredential(response.BrokerErrorCode, 64)) diff --git a/package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs b/package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs index 83a7a1210..f90219f71 100644 --- a/package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs +++ b/package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs @@ -52,13 +52,19 @@ public void DiscoveryPublishesTheFixedHelperIdentity() "CurrentUiSignerSpkiSha256", "e43ed3368eaabff61abc79eb338cba9da88a80d93b751735ff417f26afa579a8", true); + RegValue brokerPipe = CreateDiscoveryValue( + "BrokerPipeName", + @"\\.\pipe\Devolutions.Now.PackageBroker.v1", + true); Assert.Equal("[INSTALLDIR]DevolutionsAgentPolicyConsent.exe", executablePath.Value); Assert.Equal( "e43ed3368eaabff61abc79eb338cba9da88a80d93b751735ff417f26afa579a8", signer.Value); + Assert.Equal(@"\\.\pipe\Devolutions.Now.PackageBroker.v1", brokerPipe.Value); Assert.Equal(RegistryKeyAction.createAndRemoveOnUninstall, executablePath.RegistryKeyAction); Assert.Equal(RegistryKeyAction.createAndRemoveOnUninstall, signer.RegistryKeyAction); + Assert.Equal(RegistryKeyAction.createAndRemoveOnUninstall, brokerPipe.RegistryKeyAction); } private static RegValue CreateDiscoveryValue(string name, string value, bool win64) diff --git a/package/AgentWindowsManaged/Program.cs b/package/AgentWindowsManaged/Program.cs index 970b8dc87..865c93f18 100644 --- a/package/AgentWindowsManaged/Program.cs +++ b/package/AgentWindowsManaged/Program.cs @@ -359,6 +359,10 @@ static void Main() "ProductVersion", DevolutionsAgentProductVersion.ToString(), Use64BitRegistryView(project.Platform)), + CreatePolicyConsentRegistryValue( + "BrokerPipeName", + Includes.POLICY_CONSENT_DEFAULT_BROKER_PIPE_NAME, + Use64BitRegistryView(project.Platform)), CreatePolicyConsentRegistryValue( "CurrentUiSignerSpkiSha256", Includes.POLICY_CONSENT_CURRENT_UI_SIGNER_SPKI_SHA256, diff --git a/package/AgentWindowsManaged/Resources/Includes.cs b/package/AgentWindowsManaged/Resources/Includes.cs index 284f8bef2..0474decf9 100644 --- a/package/AgentWindowsManaged/Resources/Includes.cs +++ b/package/AgentWindowsManaged/Resources/Includes.cs @@ -27,6 +27,9 @@ internal static class Includes internal static readonly string POLICY_CONSENT_PROTOCOL_VERSION = DevolutionsAgentPolicyConsent.PolicyConsentContract.ProtocolVersion; + internal static readonly string POLICY_CONSENT_DEFAULT_BROKER_PIPE_NAME = + DevolutionsAgentPolicyConsent.PolicyConsentContract.DefaultBrokerPipeName; + internal static readonly string POLICY_CONSENT_CURRENT_UI_SIGNER_SPKI_SHA256 = DevolutionsAgentPolicyConsent.PolicyConsentContract.CurrentUiSignerSpkiSha256; From d6270cd4897cf2def06c761d590d4cae168d8c08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 02:01:04 +0900 Subject: [PATCH 22/39] docs(agent-installer): add helper package step Document the required NativeAOT consent helper publish and packaging argument for local Agent MSI builds. Issue: #1963 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ci/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ci/README.md b/ci/README.md index 264a9500d..1f36ee324 100644 --- a/ci/README.md +++ b/ci/README.md @@ -14,8 +14,8 @@ This folder contains PowerShell scripts for CI, building, and packaging. | Gateway | Windows (regular) | `build.ps1 gateway`
`copy-ps-module.ps1`
`package-gateway-windows.ps1` | | Gateway | Windows (assembled) | `build.ps1 gateway`
`copy-ps-module.ps1`
`package-gateway-windows.ps1 -Generate`
`package-assembled.ps1 gateway` | | Gateway | Linux | `build.ps1 gateway`
`package-gateway-linux.ps1` (not available yet) | -| Agent | Windows (regular) | `build.ps1 agent`
`build.ps1 pedm`
`build.ps1 session`
`..\dotnet\DesktopAgent\build.ps1`
`package-agent-windows.ps1` | -| Agent | Windows (assembled) | `build.ps1 agent`
`build.ps1 pedm`
`build.ps1 session`
`..\dotnet\DesktopAgent\build.ps1`
`package-agent-windows.ps1 -Generate`
`package-assembled.ps1 agent` | +| Agent | Windows (regular) | `build.ps1 agent`
`build.ps1 pedm`
`build.ps1 session`
`..\dotnet\DesktopAgent\build.ps1`
`dotnet publish ..\package\AgentPolicyConsent\DevolutionsAgentPolicyConsent.csproj -c Release -r win-x64 --self-contained`
`package-agent-windows.ps1 -PolicyConsentHelper ..\package\AgentPolicyConsent\bin\Release\net10.0-windows\win-x64\publish\DevolutionsAgentPolicyConsent.exe` | +| Agent | Windows (assembled) | `build.ps1 agent`
`build.ps1 pedm`
`build.ps1 session`
`..\dotnet\DesktopAgent\build.ps1`
`dotnet publish ..\package\AgentPolicyConsent\DevolutionsAgentPolicyConsent.csproj -c Release -r win-x64 --self-contained`
`package-agent-windows.ps1 -Generate -PolicyConsentHelper ..\package\AgentPolicyConsent\bin\Release\net10.0-windows\win-x64\publish\DevolutionsAgentPolicyConsent.exe`
`package-assembled.ps1 agent` | | Jetsocat | Windows/macOS/Linux | `build.ps1 jetsocat`
Jetsocat is not packaged. | | Session | Windows/macOS/Linux | `build.ps1 session`
Session is not packaged. | | PEDM module | Windows | `build.ps1 pedm` | From 36f921324dfb4ffd748864278d2a0a6c8aa608fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 02:24:08 +0900 Subject: [PATCH 23/39] fix(agent): require local consent parent Reject remote-provider parent images before retaining them for policy consent. Document every required local Agent package artifact argument. Issue: #1963 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ci/README.md | 22 +++++++++++++++++-- .../AgentPolicyConsent.Tests/ProtocolTests.cs | 11 ++++++++++ package/AgentPolicyConsent/PeerTrust.cs | 19 ++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/ci/README.md b/ci/README.md index 1f36ee324..bac41e802 100644 --- a/ci/README.md +++ b/ci/README.md @@ -14,14 +14,32 @@ This folder contains PowerShell scripts for CI, building, and packaging. | Gateway | Windows (regular) | `build.ps1 gateway`
`copy-ps-module.ps1`
`package-gateway-windows.ps1` | | Gateway | Windows (assembled) | `build.ps1 gateway`
`copy-ps-module.ps1`
`package-gateway-windows.ps1 -Generate`
`package-assembled.ps1 gateway` | | Gateway | Linux | `build.ps1 gateway`
`package-gateway-linux.ps1` (not available yet) | -| Agent | Windows (regular) | `build.ps1 agent`
`build.ps1 pedm`
`build.ps1 session`
`..\dotnet\DesktopAgent\build.ps1`
`dotnet publish ..\package\AgentPolicyConsent\DevolutionsAgentPolicyConsent.csproj -c Release -r win-x64 --self-contained`
`package-agent-windows.ps1 -PolicyConsentHelper ..\package\AgentPolicyConsent\bin\Release\net10.0-windows\win-x64\publish\DevolutionsAgentPolicyConsent.exe` | -| Agent | Windows (assembled) | `build.ps1 agent`
`build.ps1 pedm`
`build.ps1 session`
`..\dotnet\DesktopAgent\build.ps1`
`dotnet publish ..\package\AgentPolicyConsent\DevolutionsAgentPolicyConsent.csproj -c Release -r win-x64 --self-contained`
`package-agent-windows.ps1 -Generate -PolicyConsentHelper ..\package\AgentPolicyConsent\bin\Release\net10.0-windows\win-x64\publish\DevolutionsAgentPolicyConsent.exe`
`package-assembled.ps1 agent` | +| Agent | Windows (regular) | `build.ps1 agent`
`build.ps1 pedm`
`build.ps1 session`
`..\dotnet\DesktopAgent\build.ps1`
`dotnet publish ..\package\AgentPolicyConsent\DevolutionsAgentPolicyConsent.csproj -c Release -r win-x64 --self-contained`
`package-agent-windows.ps1` with the arguments below | +| Agent | Windows (assembled) | `build.ps1 agent`
`build.ps1 pedm`
`build.ps1 session`
`..\dotnet\DesktopAgent\build.ps1`
`dotnet publish ..\package\AgentPolicyConsent\DevolutionsAgentPolicyConsent.csproj -c Release -r win-x64 --self-contained`
`package-agent-windows.ps1 -Generate` with the arguments below
`package-assembled.ps1 agent` | | Jetsocat | Windows/macOS/Linux | `build.ps1 jetsocat`
Jetsocat is not packaged. | | Session | Windows/macOS/Linux | `build.ps1 session`
Session is not packaged. | | PEDM module | Windows | `build.ps1 pedm` | | PowerShell module | Windows | `copy-ps-module.ps1` | | Desktop Agent | Windows | `..\dotnet\DesktopAgent\build.ps1` | +## Agent Windows package arguments + +Pass every staged artifact to `package-agent-windows.ps1`. + +```powershell +.\package-agent-windows.ps1 ` + -Exe ` + -UpdaterExe ` + -PedmDll ` + -PedmMsix ` + -SessionExe ` + -PolicyConsentHelper ..\package\AgentPolicyConsent\bin\Release\net10.0-windows\win-x64\publish\DevolutionsAgentPolicyConsent.exe ` + -Architecture x64 ` + -Outfile +``` + +For an assembled package, replace `-Outfile ` with `-Generate`. + ## What is the difference between _Windows (regular)_ and _Windows (assembled)_? _Windows (regular)_ is the "normal" build process where the MSI is built by WiX but not signed. This is used in _ci.yaml_. _Windows (assembled)_ is a two-step process where the `-Generate` flag is used to build supporting files for the MSI, including DLLs, language transforms, and _cmd_ scripts. The MSI is assembled in second step using _package-assembled.ps1_. The two-step approach is described [here](https://github.com/oleg-shilo/wixsharp/wiki/Developer's-Guide#compiling-wix-project). diff --git a/package/AgentPolicyConsent.Tests/ProtocolTests.cs b/package/AgentPolicyConsent.Tests/ProtocolTests.cs index bbb7bbb5d..d3f6dceb9 100644 --- a/package/AgentPolicyConsent.Tests/ProtocolTests.cs +++ b/package/AgentPolicyConsent.Tests/ProtocolTests.cs @@ -124,6 +124,17 @@ public void ProcessIdentityAcceptsExactRetainedInstance() Assert.True(PeerLease.MatchesProcessIdentity(expected, 42, 638900000000000000, 1)); } + [Theory] + [InlineData(@"C:\Program Files\UniGetUI\UniGetUI.exe", true)] + [InlineData(@"\\server\share\UniGetUI.exe", false)] + [InlineData(@"\Device\Mup\server\share\UniGetUI.exe", false)] + [InlineData(@"\Device\WebDavRedirector\server\share\UniGetUI.exe", false)] + [InlineData(@"relative\UniGetUI.exe", false)] + public void ParentImageMustUseAFixedLocalVolume(string path, bool expected) + { + Assert.Equal(expected, PeerLease.IsSupportedLocalImagePath(path)); + } + [Fact] public void BrokerServerRequiresExactAgentSiblingPath() { diff --git a/package/AgentPolicyConsent/PeerTrust.cs b/package/AgentPolicyConsent/PeerTrust.cs index 812652fae..038f0c7a6 100644 --- a/package/AgentPolicyConsent/PeerTrust.cs +++ b/package/AgentPolicyConsent/PeerTrust.cs @@ -28,6 +28,7 @@ internal sealed class PeerLease : IDisposable internal const uint FileAttributeReparsePoint = 0x400; internal const uint FileFlagBackupSemantics = 0x0200_0000; internal const uint FileFlagOpenReparsePoint = 0x0020_0000; + internal const uint DriveFixed = 3; internal const int ProcessImageFileMapping = 44; internal const uint StillActive = 259; @@ -76,6 +77,10 @@ internal static PeerLease Open(Arguments arguments) } string path = ImagePath(process); + if (!IsSupportedLocalImagePath(path)) + { + throw new InvalidOperationException("parent image is not on a supported local volume"); + } SafeFileHandle image = Native.CreateFile( path, GenericRead | FileExecute | Synchronize, @@ -200,6 +205,17 @@ internal static bool SameFile(SafeFileHandle left, SafeFileHandle right) leftInfo.FileIndexLow == rightInfo.FileIndexLow; } + internal static bool IsSupportedLocalImagePath(string path) + { + if (!Path.IsPathFullyQualified(path) || + path.StartsWith(@"\\", StringComparison.Ordinal) || + Path.GetPathRoot(path) is not { Length: 3 } root) + { + return false; + } + return Native.GetDriveType(root) == DriveFixed; + } + internal static bool IsLocalSystemProcess(SafeProcessHandle process) { if (!Native.OpenProcessToken(process, 0x0008, out SafeAccessTokenHandle token)) @@ -814,6 +830,9 @@ internal static partial bool GetProcessTimes( [return: MarshalAs(UnmanagedType.Bool)] internal static partial bool GetExitCodeProcess(SafeProcessHandle process, out uint exitCode); + [LibraryImport("kernel32.dll", EntryPoint = "GetDriveTypeW", StringMarshalling = StringMarshalling.Utf16)] + internal static partial uint GetDriveType(string rootPathName); + [LibraryImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] internal static partial bool GetFileInformationByHandle( From 4361e0d3f36f84d11e9398c8518153111ce36fae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 02:45:05 +0900 Subject: [PATCH 24/39] fix(agent): resolve retained consent paths Resolve the retained UniGetUI image path before trusting its local volume. Keep already-correct package inputs absolute while the MSI build changes directories. Issue: #1963 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ci/package-agent-windows.ps1 | 5 +- .../AgentPolicyConsent.Tests/ProtocolTests.cs | 8 +++ package/AgentPolicyConsent/PeerTrust.cs | 49 ++++++++++++++++++- 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/ci/package-agent-windows.ps1 b/ci/package-agent-windows.ps1 index 246cfe245..bb97553a5 100644 --- a/ci/package-agent-windows.ps1 +++ b/ci/package-agent-windows.ps1 @@ -45,8 +45,9 @@ function Set-FileNameAndCopy { # If the name is already correct, return the original path without copying if ($currName -ieq $NewName) { - Write-Host "Using $Path without copying" - return $Path + $resolvedPath = (Resolve-Path -LiteralPath $Path).Path + Write-Host "Using $resolvedPath without copying" + return $resolvedPath } # Copy to a temporary directory. diff --git a/package/AgentPolicyConsent.Tests/ProtocolTests.cs b/package/AgentPolicyConsent.Tests/ProtocolTests.cs index d3f6dceb9..1d5d4573e 100644 --- a/package/AgentPolicyConsent.Tests/ProtocolTests.cs +++ b/package/AgentPolicyConsent.Tests/ProtocolTests.cs @@ -135,6 +135,14 @@ public void ParentImageMustUseAFixedLocalVolume(string path, bool expected) Assert.Equal(expected, PeerLease.IsSupportedLocalImagePath(path)); } + [Theory] + [InlineData(@"\\?\C:\Program Files\UniGetUI\UniGetUI.exe", @"C:\Program Files\UniGetUI\UniGetUI.exe")] + [InlineData(@"\\?\UNC\server\share\UniGetUI.exe", @"\\server\share\UniGetUI.exe")] + public void RetainedImagePathRemovesOnlyExtendedPrefix(string input, string expected) + { + Assert.Equal(expected, PeerLease.NormalizeFinalPath(input)); + } + [Fact] public void BrokerServerRequiresExactAgentSiblingPath() { diff --git a/package/AgentPolicyConsent/PeerTrust.cs b/package/AgentPolicyConsent/PeerTrust.cs index 038f0c7a6..52c5fa2e0 100644 --- a/package/AgentPolicyConsent/PeerTrust.cs +++ b/package/AgentPolicyConsent/PeerTrust.cs @@ -97,8 +97,13 @@ internal static PeerLease Open(Arguments arguments) try { VerifyImageMapping(process, image); - VerifyImageMetadata(path, image); - using X509Certificate2 signer = VerifyAuthenticodeSigner(path, image, "parent image"); + string retainedPath = FinalPath(image); + if (!IsSupportedLocalImagePath(retainedPath)) + { + throw new InvalidOperationException("parent image is not on a supported local volume"); + } + VerifyImageMetadata(retainedPath, image); + using X509Certificate2 signer = VerifyAuthenticodeSigner(retainedPath, image, "parent image"); VerifySigner(signer); VerifyImageMapping(process, image); EnsureActive(process); @@ -216,6 +221,39 @@ internal static bool IsSupportedLocalImagePath(string path) return Native.GetDriveType(root) == DriveFixed; } + internal static string FinalPath(SafeFileHandle image) + { + uint capacity = 260; + while (capacity <= 32_768) + { + StringBuilder path = new(checked((int)capacity)); + uint length = Native.GetFinalPathNameByHandle(image, path, capacity, 0); + if (length == 0) + { + throw new Win32Exception(); + } + if (length < capacity) + { + return NormalizeFinalPath(path.ToString()); + } + capacity = length + 1; + } + throw new InvalidOperationException("parent image final path is too long"); + } + + internal static string NormalizeFinalPath(string path) + { + const string ExtendedPrefix = @"\\?\"; + const string ExtendedUncPrefix = @"\\?\UNC\"; + if (path.StartsWith(ExtendedUncPrefix, StringComparison.OrdinalIgnoreCase)) + { + return @"\\" + path[ExtendedUncPrefix.Length..]; + } + return path.StartsWith(ExtendedPrefix, StringComparison.Ordinal) + ? path[ExtendedPrefix.Length..] + : path; + } + internal static bool IsLocalSystemProcess(SafeProcessHandle process) { if (!Native.OpenProcessToken(process, 0x0008, out SafeAccessTokenHandle token)) @@ -833,6 +871,13 @@ internal static partial bool GetProcessTimes( [LibraryImport("kernel32.dll", EntryPoint = "GetDriveTypeW", StringMarshalling = StringMarshalling.Utf16)] internal static partial uint GetDriveType(string rootPathName); + [DllImport("kernel32.dll", EntryPoint = "GetFinalPathNameByHandleW", SetLastError = true, CharSet = CharSet.Unicode)] + internal static extern uint GetFinalPathNameByHandle( + SafeFileHandle file, + StringBuilder path, + uint length, + uint flags); + [LibraryImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] internal static partial bool GetFileInformationByHandle( From 6c06b9e90dade130522d5736170963c253ec1d4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 03:08:14 +0900 Subject: [PATCH 25/39] fix(agent): align policy pipe deadline Keep the bounded broker connection lifetime aligned with the consent helper's two-minute policy replacement exchange. Issue: #1963 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/pipe.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/now-package-broker/src/pipe.rs b/crates/now-package-broker/src/pipe.rs index 88721a675..6b35afd8e 100644 --- a/crates/now-package-broker/src/pipe.rs +++ b/crates/now-package-broker/src/pipe.rs @@ -37,10 +37,11 @@ const MAX_CONCURRENT_CONNECTIONS: usize = 16; /// /// Each connection serves exactly one HTTP request (`keep_alive` is disabled) and all /// endpoints respond without blocking on package operations (execution is asynchronous, -/// tracked via the operation tracker), so a healthy exchange completes well within this -/// deadline. Without it, idle clients holding their connection open without sending a -/// request would each pin a connection slot indefinitely and could exhaust the pool. -const CONNECTION_DEADLINE: std::time::Duration = std::time::Duration::from_secs(30); +/// tracked via the operation tracker), except policy replacement, which validates and +/// persists a bounded document. Keep this aligned with the consent helper's bounded +/// exchange stage. Without it, idle clients holding their connection open without sending +/// a request would each pin a connection slot indefinitely and could exhaust the pool. +const CONNECTION_DEADLINE: std::time::Duration = std::time::Duration::from_secs(120); /// How long shutdown waits for the connections that are still serving a request, then for the /// aborted ones to actually stop. @@ -282,6 +283,11 @@ mod tests { /// Blocking work a connection can be stuck in that aborting it cannot interrupt. const NON_ABORTABLE_CONNECTION_WORK: Duration = Duration::from_secs(1); + #[test] + fn connection_deadline_matches_consent_exchange_timeout() { + assert_eq!(CONNECTION_DEADLINE, Duration::from_secs(120)); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn timed_out_capture_keeps_its_permit_until_blocking_work_finishes() { let permits = Arc::new(Semaphore::new(1)); From 7cbaae0c2b9e193d8a808f18f604650f7e192cc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 03:24:22 +0900 Subject: [PATCH 26/39] fix(agent): limit consent pipe timeout Keep ordinary pipe capture and requests bounded to 30 seconds. Allow the two-minute exchange only after the exact consent helper is authorized. Issue: #1963 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/pipe.rs | 82 +++++++++++++++------------ 1 file changed, 45 insertions(+), 37 deletions(-) diff --git a/crates/now-package-broker/src/pipe.rs b/crates/now-package-broker/src/pipe.rs index 6b35afd8e..37ca1bfe6 100644 --- a/crates/now-package-broker/src/pipe.rs +++ b/crates/now-package-broker/src/pipe.rs @@ -36,12 +36,15 @@ const MAX_CONCURRENT_CONNECTIONS: usize = 16; /// Deadline for serving a single pipe connection, from accept to response completion. /// /// Each connection serves exactly one HTTP request (`keep_alive` is disabled) and all -/// endpoints respond without blocking on package operations (execution is asynchronous, -/// tracked via the operation tracker), except policy replacement, which validates and -/// persists a bounded document. Keep this aligned with the consent helper's bounded -/// exchange stage. Without it, idle clients holding their connection open without sending -/// a request would each pin a connection slot indefinitely and could exhaust the pool. -const CONNECTION_DEADLINE: std::time::Duration = std::time::Duration::from_secs(120); +/// ordinary endpoints respond without blocking on package operations (execution is +/// asynchronous and tracked via the operation tracker). Without this deadline, idle +/// clients holding their connection open without sending a request would each pin a +/// connection slot indefinitely and could exhaust the pool. +const CONNECTION_DEADLINE: std::time::Duration = std::time::Duration::from_secs(30); + +/// The bounded policy-replacement exchange lifetime used only after the exact installed +/// consent helper has passed write authorization. +const POLICY_CONSENT_CONNECTION_DEADLINE: std::time::Duration = std::time::Duration::from_secs(120); /// How long shutdown waits for the connections that are still serving a request, then for the /// aborted ones to actually stop. @@ -110,39 +113,43 @@ async fn accept_connections( // lease keeps the recorder from closing the queue under that event. let _audit_lease = crate::audit::AuditLease::acquire(); - let serve = async move { - // Keep blocking unauthenticated capture off the accept loop and - // retain the connection slot until the work actually completes. - let capture = spawn_bounded_capture(permit, move || { - let client = PipeClient::from_connected_pipe(&server); - (server, client) - }); - let (_permit, server, client) = match capture.await { - Ok((permit, (server, Ok(client)))) => (permit, server, client), - Ok((_permit, (_server, Err(error)))) => { - warn!(error = format!("{error:#}"), "Rejected named pipe client"); - return; - } - Err(error) => { - error!( - error = format!("{error:#}"), - "Named pipe client identity capture task failed" - ); - return; - } - }; - - info!("Client connected to named pipe"); - let router = build_router_for_client(state, client); - serve_connection(server, router).await; - info!("Client disconnected from named pipe"); + // Keep blocking unauthenticated capture off the accept loop and + // retain the connection slot until the work actually completes. + let capture = spawn_bounded_capture(permit, move || { + let client = PipeClient::from_connected_pipe(&server); + (server, client) + }); + let (_permit, server, client) = + match tokio::time::timeout(CONNECTION_DEADLINE, capture).await { + Ok(Ok((permit, (server, Ok(client))))) => (permit, server, client), + Ok(Ok((_permit, (_server, Err(error))))) => { + warn!(error = format!("{error:#}"), "Rejected named pipe client"); + return; + } + Ok(Err(error)) => { + error!( + error = format!("{error:#}"), + "Named pipe client identity capture task failed" + ); + return; + } + Err(_) => { + warn!("Closed named pipe connection: client identity capture deadline exceeded"); + return; + } }; - // Enforce a deadline so idle or slow clients cannot pin - // a connection slot indefinitely. - if tokio::time::timeout(CONNECTION_DEADLINE, serve).await.is_err() { + let deadline = if client.validate_policy_write(state.skip_signature_validation).is_ok() { + POLICY_CONSENT_CONNECTION_DEADLINE + } else { + CONNECTION_DEADLINE + }; + info!("Client connected to named pipe"); + let router = build_router_for_client(state, client); + if tokio::time::timeout(deadline, serve_connection(server, router)).await.is_err() { warn!("Closed named pipe connection: deadline exceeded"); } + info!("Client disconnected from named pipe"); }); } Err(error) => { @@ -284,8 +291,9 @@ mod tests { const NON_ABORTABLE_CONNECTION_WORK: Duration = Duration::from_secs(1); #[test] - fn connection_deadline_matches_consent_exchange_timeout() { - assert_eq!(CONNECTION_DEADLINE, Duration::from_secs(120)); + fn connection_deadlines_preserve_short_untrusted_and_long_authorized_bounds() { + assert_eq!(CONNECTION_DEADLINE, Duration::from_secs(30)); + assert_eq!(POLICY_CONSENT_CONNECTION_DEADLINE, Duration::from_secs(120)); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] From 86aa026358fe55439363e852006cdb1417639d90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 03:45:02 +0900 Subject: [PATCH 27/39] fix(agent): extend authorized policy writes Keep unauthenticated and ordinary pipe connections at 30 seconds. Extend only a successfully authorized consent-helper policy write to the two-minute exchange limit. Issue: #1963 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/pipe.rs | 40 ++++++++++--- crates/now-package-broker/src/server/mod.rs | 66 ++++++++++++++++++++- 2 files changed, 96 insertions(+), 10 deletions(-) diff --git a/crates/now-package-broker/src/pipe.rs b/crates/now-package-broker/src/pipe.rs index 37ca1bfe6..6ecab0eec 100644 --- a/crates/now-package-broker/src/pipe.rs +++ b/crates/now-package-broker/src/pipe.rs @@ -20,7 +20,7 @@ use windows::Win32::Security::Authorization::SET_ACCESS; use windows::Win32::Storage::FileSystem::{FILE_GENERIC_READ, FILE_GENERIC_WRITE}; use crate::auth::PipeClient; -use crate::server::{BrokerState, build_router_for_client, serve_connection}; +use crate::server::{BrokerState, build_router_for_client_with_policy_write_deadline, serve_connection}; /// Default pipe name for the package broker. pub const DEFAULT_PIPE_NAME: &str = r"\\.\pipe\Devolutions.Now.PackageBroker.v1"; @@ -139,15 +139,37 @@ async fn accept_connections( } }; - let deadline = if client.validate_policy_write(state.skip_signature_validation).is_ok() { - POLICY_CONSENT_CONNECTION_DEADLINE - } else { - CONNECTION_DEADLINE - }; info!("Client connected to named pipe"); - let router = build_router_for_client(state, client); - if tokio::time::timeout(deadline, serve_connection(server, router)).await.is_err() { - warn!("Closed named pipe connection: deadline exceeded"); + let (policy_write_deadline, mut policy_write_authorized) = tokio::sync::watch::channel(false); + let router = build_router_for_client_with_policy_write_deadline( + state, + client, + policy_write_deadline, + ); + let serve = serve_connection(server, router); + tokio::pin!(serve); + let connection_deadline = tokio::time::sleep(CONNECTION_DEADLINE); + tokio::pin!(connection_deadline); + let policy_deadline = tokio::time::sleep(POLICY_CONSENT_CONNECTION_DEADLINE); + tokio::pin!(policy_deadline); + let mut policy_write_is_authorized = false; + loop { + tokio::select! { + () = &mut serve => break, + () = &mut connection_deadline, if !policy_write_is_authorized => { + warn!("Closed named pipe connection: deadline exceeded"); + break; + } + () = &mut policy_deadline, if policy_write_is_authorized => { + warn!("Closed named pipe policy replacement: deadline exceeded"); + break; + } + result = policy_write_authorized.changed(), if !policy_write_is_authorized => { + if result.is_ok() && *policy_write_authorized.borrow_and_update() { + policy_write_is_authorized = true; + } + } + } } info!("Client disconnected from named pipe"); }); diff --git a/crates/now-package-broker/src/server/mod.rs b/crates/now-package-broker/src/server/mod.rs index 77f835d87..4a2c97029 100644 --- a/crates/now-package-broker/src/server/mod.rs +++ b/crates/now-package-broker/src/server/mod.rs @@ -26,6 +26,7 @@ use now_policy_api::{ use now_policy_server_template::{ MAX_POLICY_MANAGEMENT_BODY_BYTES, MAX_REQUEST_BODY_BYTES, PackageBrokerServer, SharedPackageBrokerServer, }; +use tokio::sync::watch; use tracing::{info, trace, warn}; use win_api_wrappers::identity::sid::Sid; @@ -113,6 +114,23 @@ struct EvaluatedRequest { /// Build the axum router for a single authenticated pipe client. pub(crate) fn build_router_for_client(state: Arc, client: PipeClient) -> axum::Router { + build_router_for_client_with_optional_policy_write_deadline(state, client, None) +} + +/// Build the router and signal when the exact helper policy-write authorization completes. +pub(crate) fn build_router_for_client_with_policy_write_deadline( + state: Arc, + client: PipeClient, + policy_write_deadline: watch::Sender, +) -> axum::Router { + build_router_for_client_with_optional_policy_write_deadline(state, client, Some(policy_write_deadline)) +} + +fn build_router_for_client_with_optional_policy_write_deadline( + state: Arc, + client: PipeClient, + policy_write_deadline: Option>, +) -> axum::Router { let server: SharedPackageBrokerServer = Arc::new(BrokerConnection { state: Arc::clone(&state), client: client.clone(), @@ -120,6 +138,7 @@ pub(crate) fn build_router_for_client(state: Arc, client: PipeClien axum::Router::from(now_policy_server_template::api_router_from_shared(server)) .layer(middleware::from_fn(reject_duplicate_policy_json_members)) .layer(middleware::from_fn_with_state(state, authenticate_policy_management)) + .layer(Extension(policy_write_deadline)) .layer(Extension(client)) } @@ -292,6 +311,7 @@ fn reject_duplicate_json_members(bytes: &[u8]) -> Result<(), serde_json::Error> async fn authenticate_policy_management( State(state): State>, Extension(client): Extension, + Extension(policy_write_deadline): Extension>>, request: Request, next: Next, ) -> Response { @@ -307,7 +327,8 @@ async fn authenticate_policy_management( | (&Method::PUT, "/v1/policy") ); if protected { - let authentication = if matches!((request.method(), request.uri().path()), (&Method::PUT, "/v1/policy")) { + let policy_write = matches!((request.method(), request.uri().path()), (&Method::PUT, "/v1/policy")); + let authentication = if policy_write { client.validate_policy_write(state.skip_signature_validation) } else { client.validate_connection(state.skip_signature_validation) @@ -326,6 +347,9 @@ async fn authenticate_policy_management( ) .into_response(); } + if policy_write && let Some(policy_write_deadline) = policy_write_deadline { + let _ = policy_write_deadline.send(true); + } let authenticated = POLICY_MANAGEMENT_AUTHENTICATED.scope((), next.run(request)); return if let Some(audit) = write_audit { POLICY_WRITE_AUDIT.scope(audit, authenticated).await @@ -1075,6 +1099,46 @@ mod tests { } } + #[cfg(feature = "dev-skip-broker-signature")] + #[tokio::test] + async fn authorized_policy_write_extends_only_its_connection_deadline() { + let client = PipeClient::test_with_authority(true, true).expect("create elevated test client"); + let state = shared_state(None); + let draft = serde_json::json!({ + "PolicyFormatVersion": "1.0.0", + "Metadata": { "Id": "replacement", "Publisher": "Test" }, + "Enforcement": { "DefaultDecision": "Deny" }, + "Rules": [] + }); + let validation = state.policy_store.validate_draft(&draft); + let replacement = serde_json::json!({ + "RequestKind": "PolicyReplacementRequest", + "RequestVersion": "1.0", + "ExpectedStoreToken": state.policy_store.management_snapshot().store_token, + "Operation": "Create", + "ConflictHandling": "Reject", + "WarningsAcknowledged": false, + "Draft": draft, + "ValidationReceipt": validation.validation_receipt.expect("valid receipt"), + }); + let (deadline, mut extended) = watch::channel(false); + let mut router = build_router_for_client_with_policy_write_deadline(state, client, deadline); + let response = router + .call( + Request::builder() + .method(Method::PUT) + .uri("/v1/policy") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&replacement).expect("serialize request"))) + .expect("valid request"), + ) + .await + .expect("router is infallible"); + + assert_eq!(response.status(), StatusCode::OK); + assert!(*extended.borrow_and_update()); + } + #[cfg(feature = "dev-skip-broker-signature")] async fn route_json( state: Arc, From 7b1446acf1e0bae1724efe41a88d7a9e5b0bf2ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 04:17:01 +0900 Subject: [PATCH 28/39] fix(agent): recover interrupted policy probes Retire only verified protected probe remnants after an interrupted capability check, and require retained Agent ancestor handles to remain expected directories. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/policy_store/windows.rs | 132 ++++++++++++++++-- crates/now-package-broker/src/server/mod.rs | 1 + .../AgentPolicyConsent.Tests/ProtocolTests.cs | 2 + package/AgentPolicyConsent/PeerTrust.cs | 18 +++ 4 files changed, 141 insertions(+), 12 deletions(-) diff --git a/crates/now-package-broker/src/policy_store/windows.rs b/crates/now-package-broker/src/policy_store/windows.rs index 972029cf2..52b802742 100644 --- a/crates/now-package-broker/src/policy_store/windows.rs +++ b/crates/now-package-broker/src/policy_store/windows.rs @@ -34,10 +34,11 @@ use windows::Win32::Storage::FileSystem::{ CREATE_NEW, CreateFileW, DELETE, FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_NORMAL, FILE_ATTRIBUTE_REPARSE_POINT, FILE_DISPOSITION_FLAG_DELETE, FILE_DISPOSITION_FLAG_IGNORE_READONLY_ATTRIBUTE, FILE_DISPOSITION_FLAG_POSIX_SEMANTICS, FILE_DISPOSITION_INFO_EX, FILE_DISPOSITION_INFO_EX_FLAGS, - FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_FLAG_WRITE_THROUGH, FILE_GENERIC_READ, - FILE_LIST_DIRECTORY, FILE_READ_ATTRIBUTES, FILE_RENAME_INFO, FILE_RENAME_INFO_0, FILE_SHARE_DELETE, - FILE_SHARE_NONE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_TRAVERSE, FileDispositionInfoEx, FileRenameInfo, - FileRenameInfoEx, GetVolumeInformationW, GetVolumePathNameW, READ_CONTROL, SetFileInformationByHandle, + FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_DELETE_ON_CLOSE, FILE_FLAG_OPEN_REPARSE_POINT, FILE_FLAG_WRITE_THROUGH, + FILE_GENERIC_READ, FILE_LIST_DIRECTORY, FILE_READ_ATTRIBUTES, FILE_RENAME_INFO, FILE_RENAME_INFO_0, + FILE_SHARE_DELETE, FILE_SHARE_NONE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_TRAVERSE, FileDispositionInfoEx, + FileRenameInfo, FileRenameInfoEx, GetVolumeInformationW, GetVolumePathNameW, READ_CONTROL, + SetFileInformationByHandle, }; #[cfg(test)] use windows::Win32::Storage::FileSystem::{MOVEFILE_REPLACE_EXISTING, MoveFileExW}; @@ -544,6 +545,11 @@ impl std::error::Error for UnsupportedAtomicSemantics {} /// Filesystem names known to support atomic same-directory handle renames. /// Conservative by design: an unrecognized filesystem is treated as unsupported. const ATOMIC_REPLACE_CAPABLE_FILESYSTEMS: &[&str] = &["NTFS", "ReFS"]; +const PROBE_SOURCE_NAME: &str = ".package-broker-write-probe-a.tmp"; +const PROBE_TARGET_NAME: &str = ".package-broker-write-probe-b.tmp"; +const PROBE_TOMBSTONE_NAME: &str = ".package-broker-write-probe-old.tmp"; +const PROBE_SOURCE_CONTENT: &[u8] = b"probe-source"; +const PROBE_TARGET_CONTENT: &[u8] = b"probe-target"; /// Verifies that `dir` supports the handle-based tombstone and create-new publication semantics required by [`atomic_replace`]. /// @@ -559,11 +565,12 @@ fn probe_write_capability(dir: &Path) -> anyhow::Result<()> { } let dir_handle = open_directory_no_reparse(dir)?; - let source_path = dir.join(".package-broker-write-probe-a.tmp"); - let target_path = dir.join(".package-broker-write-probe-b.tmp"); - let tombstone_path = dir.join(".package-broker-write-probe-old.tmp"); - let source = create_probe_file(&source_path, b"probe-source", false)?; - let target = match create_probe_file(&target_path, b"probe-target", true) { + recover_interrupted_write_capability_probe(&dir_handle, dir)?; + let source_path = dir.join(PROBE_SOURCE_NAME); + let target_path = dir.join(PROBE_TARGET_NAME); + let tombstone_path = dir.join(PROBE_TOMBSTONE_NAME); + let source = create_probe_file(&source_path, PROBE_SOURCE_CONTENT, false)?; + let target = match create_probe_file(&target_path, PROBE_TARGET_CONTENT, true) { Ok(target) => target, Err(error) => { return match cleanup_probe_file(source, &source_path, "write-capability probe source") { @@ -595,7 +602,7 @@ fn probe_write_capability(dir: &Path) -> anyhow::Result<()> { source_published = true; let replaced = std::fs::read(&target_path).context("read write-capability probe result")?; ensure!( - replaced == b"probe-source", + replaced == PROBE_SOURCE_CONTENT, "atomic replacement did not take effect on this filesystem" ); delete_file_handle(&target).context("probe POSIX tombstone unlink")?; @@ -620,6 +627,59 @@ fn probe_write_capability(dir: &Path) -> anyhow::Result<()> { probe_result.and(source_cleanup).and(target_cleanup) } +/// Retire a trusted remnant from a capability probe interrupted before its +/// delete-on-close handles were released. +/// +/// The fixed names are only reclaimable when the entry is a non-reparse, +/// single-link, managed-policy file containing one of the probe's exact payloads. +/// This preserves fail-closed collision handling for untrusted lookalikes. +fn recover_interrupted_write_capability_probe(dir: &File, dir_path: &Path) -> anyhow::Result<()> { + verify_directory_handle_type(dir, "write-capability probe directory")?; + + for (name, expected_contents) in [ + (PROBE_SOURCE_NAME, &[PROBE_SOURCE_CONTENT][..]), + (PROBE_TARGET_NAME, &[PROBE_TARGET_CONTENT, PROBE_SOURCE_CONTENT][..]), + (PROBE_TOMBSTONE_NAME, &[PROBE_TARGET_CONTENT][..]), + ] { + let path = dir_path.join(name); + let file = match OpenOptions::new() + .access_mode(FILE_GENERIC_READ.0 | DELETE.0 | READ_CONTROL.0) + .share_mode(FILE_SHARE_READ.0) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT.0) + .open(&path) + { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => { + return Err(error) + .with_context(|| format!("failed to open interrupted write probe {}", path.display())); + } + }; + + policy_security::verify_policy_file_path(&file, &path) + .with_context(|| format!("interrupted write probe {} is unsafe", path.display()))?; + policy_security::verify_managed_policy_file_security(&file) + .with_context(|| format!("interrupted write probe {} has unsafe security", path.display()))?; + ensure!( + policy_security::file_link_count(&file)? == 1, + "interrupted write probe {} has multiple hard links", + path.display() + ); + let content = read_file_from_start(&file) + .with_context(|| format!("failed to read interrupted write probe {}", path.display()))?; + ensure!( + expected_contents.contains(&content.as_slice()), + "interrupted write probe {} has unexpected content", + path.display() + ); + delete_file_handle(&file) + .with_context(|| format!("failed to retire interrupted write probe {}", path.display()))?; + drop(file); + ensure_path_absent(&path, "interrupted write probe")?; + } + Ok(()) +} + fn verify_no_replace_collision( source: &File, target: &File, @@ -775,7 +835,7 @@ fn create_probe_file(path: &Path, bytes: &[u8], allow_delete_share: bool) -> any } .0, ) - .custom_flags((FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_WRITE_THROUGH).0) + .custom_flags((FILE_FLAG_DELETE_ON_CLOSE | FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_WRITE_THROUGH).0) .open(path) .with_context(|| format!("failed to create write-capability probe {}", path.display()))?; if let Err(error) = file @@ -4148,7 +4208,7 @@ mod tests { // ─── probe_write_capability / volume_filesystem_name ────────────────────── // - // No elevation required: these never touch `admin_only_security_attributes`. + // No elevation required: ordinary probes use inherited directory security. #[test] fn volume_filesystem_name_reports_a_known_filesystem_for_a_temp_directory() { @@ -4171,6 +4231,54 @@ mod tests { assert!(leftover.is_empty(), "probe left files behind: {leftover:?}"); } + #[test] + fn probe_file_is_removed_when_its_handle_closes() { + let dir = temp_dir(); + let path = dir.path().join(PROBE_SOURCE_NAME); + let file = create_probe_file(&path, PROBE_SOURCE_CONTENT, false).unwrap(); + + drop(file); + + assert!(!path.exists(), "delete-on-close probe file survived its handle"); + } + + #[test] + fn interrupted_trusted_probe_remnant_is_recovered() { + use std::io::Write as _; + + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let path = dir.path().join(PROBE_SOURCE_NAME); + let mut file = match create_secure_transaction_file(&path) { + Ok(file) => file, + Err(error) => { + tracing::warn!( + error = %format!("{error:#}"), + "Skipping administrator-owned probe recovery fixture" + ); + return; + } + }; + file.write_all(PROBE_SOURCE_CONTENT).unwrap(); + file.sync_all().unwrap(); + drop(file); + + recover_interrupted_write_capability_probe(&dir_file, dir.path()).unwrap(); + + assert!(!path.exists(), "trusted interrupted probe remnant was not retired"); + } + + #[test] + fn interrupted_untrusted_probe_remnant_is_not_removed() { + let dir = temp_dir(); + let dir_file = open_directory_no_reparse(dir.path()).unwrap(); + let path = dir.path().join(PROBE_SOURCE_NAME); + std::fs::write(&path, PROBE_SOURCE_CONTENT).unwrap(); + + assert!(recover_interrupted_write_capability_probe(&dir_file, dir.path()).is_err()); + assert!(path.exists(), "untrusted probe collision must remain fail-closed"); + } + #[test] fn occupied_no_replace_probe_preserves_both_retained_files() { let dir = temp_dir(); diff --git a/crates/now-package-broker/src/server/mod.rs b/crates/now-package-broker/src/server/mod.rs index 4a2c97029..fbba6157c 100644 --- a/crates/now-package-broker/src/server/mod.rs +++ b/crates/now-package-broker/src/server/mod.rs @@ -113,6 +113,7 @@ struct EvaluatedRequest { } /// Build the axum router for a single authenticated pipe client. +#[cfg(test)] pub(crate) fn build_router_for_client(state: Arc, client: PipeClient) -> axum::Router { build_router_for_client_with_optional_policy_write_deadline(state, client, None) } diff --git a/package/AgentPolicyConsent.Tests/ProtocolTests.cs b/package/AgentPolicyConsent.Tests/ProtocolTests.cs index 1d5d4573e..389538c87 100644 --- a/package/AgentPolicyConsent.Tests/ProtocolTests.cs +++ b/package/AgentPolicyConsent.Tests/ProtocolTests.cs @@ -81,6 +81,8 @@ public void ProtectedAgentPathRejectsReparsePoints() { Assert.True(PeerLease.IsReparsePoint(PeerLease.FileAttributeReparsePoint)); Assert.False(PeerLease.IsReparsePoint(0)); + Assert.True(PeerLease.IsDirectory(PeerLease.FileAttributeDirectory)); + Assert.False(PeerLease.IsDirectory(0)); } [Fact] diff --git a/package/AgentPolicyConsent/PeerTrust.cs b/package/AgentPolicyConsent/PeerTrust.cs index 52c5fa2e0..82fefd51a 100644 --- a/package/AgentPolicyConsent/PeerTrust.cs +++ b/package/AgentPolicyConsent/PeerTrust.cs @@ -25,6 +25,7 @@ internal sealed class PeerLease : IDisposable internal const uint FileShareRead = 0x1; internal const uint FileShareWrite = 0x2; internal const uint OpenExisting = 3; + internal const uint FileAttributeDirectory = 0x10; internal const uint FileAttributeReparsePoint = 0x400; internal const uint FileFlagBackupSemantics = 0x0200_0000; internal const uint FileFlagOpenReparsePoint = 0x0020_0000; @@ -453,6 +454,9 @@ internal static void VerifyProtectedPath(SafeFileHandle handle, string subject, internal static bool IsReparsePoint(uint attributes) => (attributes & FileAttributeReparsePoint) != 0; + internal static bool IsDirectory(uint attributes) => + (attributes & FileAttributeDirectory) != 0; + internal const int FileTamperRights = 0x0000_0002 | 0x0000_0004 | 0x0000_0010 | 0x0000_0100 | 0x0001_0000 | 0x0004_0000 | 0x0008_0000 | 0x1000_0000 | 0x4000_0000; @@ -724,6 +728,20 @@ private static List RetainProtectedDirectories(string installati handle, $"Agent installation directory '{directory.FullName}'", tamperRights); + if (!Native.GetFileInformationByHandle(handle, out Native.ByHandleFileInformation information)) + { + throw new Win32Exception(); + } + if (!PeerLease.IsDirectory(information.FileAttributes)) + { + throw new InvalidOperationException( + $"Agent installation directory '{directory.FullName}' is not a directory"); + } + if (!IsExpectedPath(PeerLease.FinalPath(handle), directory.FullName)) + { + throw new InvalidOperationException( + $"Agent installation directory '{directory.FullName}' resolved to an unexpected path"); + } tamperRights = PeerLease.AncestorDirectoryTamperRights; } return handles; From 07858fc688bb5614f3cd7493d2f5d2e616d75931 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 04:32:18 +0900 Subject: [PATCH 29/39] fix(agent): match Unicode policy sources Use Unicode-aware literal matching for source names so a policy deny uses the same case semantics as PowerShell repository lookup. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/evaluator/matching.rs | 7 +++- .../now-package-broker/src/evaluator/tests.rs | 40 ++++++++++++++++++- .../src/evaluator/wildcard.rs | 18 +++++++++ 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/crates/now-package-broker/src/evaluator/matching.rs b/crates/now-package-broker/src/evaluator/matching.rs index 4ee5301a9..782070c21 100644 --- a/crates/now-package-broker/src/evaluator/matching.rs +++ b/crates/now-package-broker/src/evaluator/matching.rs @@ -9,7 +9,7 @@ use now_policy_api::PackageRequest; use super::RequestFlags; use super::constraints::constraints_pass; -use super::wildcard::wildcard_any; +use super::wildcard::{literal_case_insensitive_match, wildcard_any}; pub(super) fn rule_matches( rule: &PolicyRule, @@ -130,7 +130,10 @@ fn elevation_match(elevation: now_policy_api::Elevation, allowed: &BTreeSet) -> bool { - allowed.is_empty() || allowed.iter().any(|source| source.as_ref().eq_ignore_ascii_case(value)) + allowed.is_empty() + || allowed + .iter() + .any(|source| literal_case_insensitive_match(value, source.as_ref())) } fn package_identifiers_match( diff --git a/crates/now-package-broker/src/evaluator/tests.rs b/crates/now-package-broker/src/evaluator/tests.rs index 8b6776a08..e146f53f6 100644 --- a/crates/now-package-broker/src/evaluator/tests.rs +++ b/crates/now-package-broker/src/evaluator/tests.rs @@ -5,7 +5,7 @@ use std::collections::BTreeSet; use chrono::Utc; use now_policy::{ Decision, PackageIdentifier, PackageIdentifierCondition, PolicyDocument, PolicyEnforcement, PolicyFormatVersion, - PolicyMatch, PolicyMetadata, PolicyRule, ResourceId, + PolicyMatch, PolicyMetadata, PolicyRule, ResourceId, SourceName, }; use now_policy_api::{self as api, PackageRequest}; @@ -128,6 +128,44 @@ fn deny_unmatched_package() { assert_eq!(result.rule_id, ""); } +#[test] +fn unicode_case_equivalent_source_deny_outranks_allow() { + let policy = make_policy( + Decision::Deny, + vec![ + PolicyRule { + id: ResourceId::from("allow-any"), + enabled: true, + priority: 100, + decision: Decision::Allow, + reason: None, + match_criteria: PolicyMatch::default(), + constraints: None, + }, + PolicyRule { + id: ResourceId::from("deny-corp"), + enabled: true, + priority: 100, + decision: Decision::Deny, + reason: None, + match_criteria: PolicyMatch { + source_names: BTreeSet::from([SourceName::parse("CÖRP").expect("valid source")]), + ..Default::default() + }, + constraints: None, + }, + ], + ); + let mut request = make_request(api::Operation::Install, "Example.Package"); + request.manager = api::ManagerName::PowerShell; + request.source.name = "cörp".to_owned(); + + let result = evaluate(&policy, &request); + + assert_eq!(result.decision, Decision::Deny); + assert_eq!(result.rule_id, "deny-corp"); +} + #[test] fn disabled_rules_are_ignored() { let policy = make_policy( diff --git a/crates/now-package-broker/src/evaluator/wildcard.rs b/crates/now-package-broker/src/evaluator/wildcard.rs index 68e99df85..4e75e6954 100644 --- a/crates/now-package-broker/src/evaluator/wildcard.rs +++ b/crates/now-package-broker/src/evaluator/wildcard.rs @@ -10,6 +10,18 @@ pub(super) fn wildcard_any_vec>(value: &str, patterns: &[S]) -> bo patterns.iter().any(|pattern| wildcard_match(value, pattern.as_ref())) } +/// Match an exact source name using Unicode-aware, case-insensitive semantics. +/// +/// Source names are literals, so escaping before enabling case-insensitive regex +/// matching preserves a literal `*` rather than applying wildcard semantics. +pub(super) fn literal_case_insensitive_match(value: &str, expected: &str) -> bool { + let regex_pattern = format!("^{}$", regex::escape(expected)); + regex::RegexBuilder::new(®ex_pattern) + .case_insensitive(true) + .build() + .is_ok_and(|re| re.is_match(value)) +} + fn wildcard_match(value: &str, pattern: &str) -> bool { // Convert glob pattern to regex: escape everything except *, which becomes .* let regex_pattern = format!("^{}$", regex::escape(pattern).replace(r"\*", ".*")); @@ -47,4 +59,10 @@ mod tests { assert!(wildcard_any("Contoso.Tools+", &patterns)); assert!(!wildcard_any("Contoso.Toolss", &patterns)); } + + #[test] + fn literal_match_uses_unicode_case_insensitive_semantics() { + assert!(literal_case_insensitive_match("cörp", "CÖRP")); + assert!(!literal_case_insensitive_match("cörp", "CÖRP*")); + } } From 725e3681e26989eedcbad37d89c021c15e6e7624 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 04:43:08 +0900 Subject: [PATCH 30/39] fix(agent): normalize policy source names Normalize source names before ordinal matching so policy evaluation uses the same canonical repository identity as PowerShell. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 1 + crates/now-package-broker/Cargo.toml | 1 + .../src/evaluator/wildcard.rs | 23 +++++++++++-------- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 661527ab8..d1c23ffd6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4844,6 +4844,7 @@ dependencies = [ "tokio-util", "tower-service", "tracing", + "unicode-normalization", "uuid", "widestring 1.2.1", "win-api-wrappers", diff --git a/crates/now-package-broker/Cargo.toml b/crates/now-package-broker/Cargo.toml index 337463431..1664564e7 100644 --- a/crates/now-package-broker/Cargo.toml +++ b/crates/now-package-broker/Cargo.toml @@ -49,6 +49,7 @@ tokio = { version = "1.52", features = ["net", "io-util", "rt", "macros", "parki tokio-util = "0.7" tower-service = "0.3" tracing = "0.1" +unicode-normalization = "0.1" uuid = { version = "1.23", features = ["v4"] } widestring = "1.2" win-api-wrappers = { path = "../win-api-wrappers" } diff --git a/crates/now-package-broker/src/evaluator/wildcard.rs b/crates/now-package-broker/src/evaluator/wildcard.rs index 4e75e6954..d8a201467 100644 --- a/crates/now-package-broker/src/evaluator/wildcard.rs +++ b/crates/now-package-broker/src/evaluator/wildcard.rs @@ -2,6 +2,9 @@ use std::collections::BTreeSet; +use unicode_normalization::UnicodeNormalization as _; +use windows::Win32::Globalization::{CSTR_EQUAL, CompareStringOrdinal}; + pub(super) fn wildcard_any>(value: &str, patterns: &BTreeSet) -> bool { patterns.is_empty() || patterns.iter().any(|pattern| wildcard_match(value, pattern.as_ref())) } @@ -10,16 +13,17 @@ pub(super) fn wildcard_any_vec>(value: &str, patterns: &[S]) -> bo patterns.iter().any(|pattern| wildcard_match(value, pattern.as_ref())) } -/// Match an exact source name using Unicode-aware, case-insensitive semantics. +/// Match an exact source name using the PowerShell repository identity semantics. /// -/// Source names are literals, so escaping before enabling case-insensitive regex -/// matching preserves a literal `*` rather than applying wildcard semantics. +/// PowerShell resolves repository names after canonical Unicode normalization with +/// ordinal case-insensitive comparison. +/// Source names are literals, so this deliberately does not apply wildcard semantics. pub(super) fn literal_case_insensitive_match(value: &str, expected: &str) -> bool { - let regex_pattern = format!("^{}$", regex::escape(expected)); - regex::RegexBuilder::new(®ex_pattern) - .case_insensitive(true) - .build() - .is_ok_and(|re| re.is_match(value)) + let value: Vec = value.nfc().collect::().encode_utf16().collect(); + let expected: Vec = expected.nfc().collect::().encode_utf16().collect(); + + // SAFETY: The binding marshals both valid UTF-8 strings as bounded UTF-16. + unsafe { CompareStringOrdinal(&value, &expected, true) == CSTR_EQUAL } } fn wildcard_match(value: &str, pattern: &str) -> bool { @@ -62,7 +66,8 @@ mod tests { #[test] fn literal_match_uses_unicode_case_insensitive_semantics() { - assert!(literal_case_insensitive_match("cörp", "CÖRP")); + assert!(literal_case_insensitive_match("CO\u{0308}RP", "CÖRP")); + assert!(!literal_case_insensitive_match("P\u{017F}Gallery", "PSGallery")); assert!(!literal_case_insensitive_match("cörp", "CÖRP*")); } } From 0da6ed3859921495cc2d2bbbcadb3abb39d26268 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 04:50:56 +0900 Subject: [PATCH 31/39] fix(agent): reject ambiguous policy sources Reject source spellings containing default-ignorable characters before PowerShell can resolve them to a different policy identity. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/evaluator/mod.rs | 6 ++++++ .../now-package-broker/src/evaluator/tests.rs | 8 +++++++- .../src/evaluator/wildcard.rs | 18 ++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/now-package-broker/src/evaluator/mod.rs b/crates/now-package-broker/src/evaluator/mod.rs index d0376caf8..9d09208c5 100644 --- a/crates/now-package-broker/src/evaluator/mod.rs +++ b/crates/now-package-broker/src/evaluator/mod.rs @@ -113,6 +113,12 @@ pub fn evaluate(policy: &PolicyDocument, request: &PackageRequest) -> PolicyDeci } } +/// Whether a source spelling has a stable identity across package-manager lookup and +/// policy evaluation. +pub(crate) fn source_name_is_unambiguous(source_name: &str) -> bool { + !wildcard::has_default_ignorable_code_point(source_name) +} + pub(crate) fn effective_execution_elevation(request: &PackageRequest) -> Elevation { if request.options.scope == Some(Scope::Machine) || request.client.requested_elevation == Elevation::Elevated { Elevation::Elevated diff --git a/crates/now-package-broker/src/evaluator/tests.rs b/crates/now-package-broker/src/evaluator/tests.rs index e146f53f6..ec0993278 100644 --- a/crates/now-package-broker/src/evaluator/tests.rs +++ b/crates/now-package-broker/src/evaluator/tests.rs @@ -9,7 +9,7 @@ use now_policy::{ }; use now_policy_api::{self as api, PackageRequest}; -use super::evaluate; +use super::{evaluate, source_name_is_unambiguous}; fn make_policy(default_decision: Decision, rules: Vec) -> PolicyDocument { PolicyDocument { @@ -166,6 +166,12 @@ fn unicode_case_equivalent_source_deny_outranks_allow() { assert_eq!(result.rule_id, "deny-corp"); } +#[test] +fn default_ignorable_source_spelling_is_rejected_before_evaluation() { + assert!(!source_name_is_unambiguous("PS\u{00AD}Gallery")); + assert!(source_name_is_unambiguous("PSGallery")); +} + #[test] fn disabled_rules_are_ignored() { let policy = make_policy( diff --git a/crates/now-package-broker/src/evaluator/wildcard.rs b/crates/now-package-broker/src/evaluator/wildcard.rs index d8a201467..fc586e9a8 100644 --- a/crates/now-package-broker/src/evaluator/wildcard.rs +++ b/crates/now-package-broker/src/evaluator/wildcard.rs @@ -1,10 +1,14 @@ //! Case-insensitive wildcard matching helpers. use std::collections::BTreeSet; +use std::sync::LazyLock; use unicode_normalization::UnicodeNormalization as _; use windows::Win32::Globalization::{CSTR_EQUAL, CompareStringOrdinal}; +static DEFAULT_IGNORABLE_CODE_POINT: LazyLock = + LazyLock::new(|| regex::Regex::new(r"\p{Default_Ignorable_Code_Point}").expect("valid Unicode property regex")); + pub(super) fn wildcard_any>(value: &str, patterns: &BTreeSet) -> bool { patterns.is_empty() || patterns.iter().any(|pattern| wildcard_match(value, pattern.as_ref())) } @@ -26,6 +30,14 @@ pub(super) fn literal_case_insensitive_match(value: &str, expected: &str) -> boo unsafe { CompareStringOrdinal(&value, &expected, true) == CSTR_EQUAL } } +/// Default-ignorable characters are rejected before matching and command building. +/// +/// PowerShell repository lookup ignores them, while an opaque package request would +/// otherwise preserve them for `-Repository` and make policy identity ambiguous. +pub(super) fn has_default_ignorable_code_point(value: &str) -> bool { + DEFAULT_IGNORABLE_CODE_POINT.is_match(value) +} + fn wildcard_match(value: &str, pattern: &str) -> bool { // Convert glob pattern to regex: escape everything except *, which becomes .* let regex_pattern = format!("^{}$", regex::escape(pattern).replace(r"\*", ".*")); @@ -70,4 +82,10 @@ mod tests { assert!(!literal_case_insensitive_match("P\u{017F}Gallery", "PSGallery")); assert!(!literal_case_insensitive_match("cörp", "CÖRP*")); } + + #[test] + fn default_ignorable_source_characters_are_detected() { + assert!(has_default_ignorable_code_point("PS\u{00AD}Gallery")); + assert!(!has_default_ignorable_code_point("CÖRP")); + } } From b9c33dd73636610e6a135c9b1cc2b6b87cc84a78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 04:51:02 +0900 Subject: [PATCH 32/39] fix(agent): validate package source identity Reject default-ignorable source spellings before policy evaluation and command construction can disagree. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/server/mod.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/now-package-broker/src/server/mod.rs b/crates/now-package-broker/src/server/mod.rs index fbba6157c..46fa5dfa5 100644 --- a/crates/now-package-broker/src/server/mod.rs +++ b/crates/now-package-broker/src/server/mod.rs @@ -754,6 +754,17 @@ impl BrokerState { reason = "the shared API contract requires ErrorResponse values" )] fn evaluate_request(&self, request: &PackageRequest) -> Result { + if !evaluator::source_name_is_unambiguous(&request.source.name) { + warn!( + request_id = %request.request_id, + "Rejecting request: package source name contains default-ignorable characters" + ); + return Err(error_response( + ErrorCode::ValidationFailed, + "package source name contains unsupported default-ignorable characters", + )); + } + // SECURITY: Pre/post operation commands are raw command strings executed via // cmd.exe with the execution token, and the policy schema cannot restrict // their content yet. Running them elevated would grant arbitrary elevated From 7abc41664acaf79ea0596c2f4c2dc52d09c6cb68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 04:56:29 +0900 Subject: [PATCH 33/39] fix(agent): bound pipe connections from accept Carry the ordinary pipe deadline through client capture and serving so unauthenticated clients cannot reserve a connection slot twice as long. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/pipe.rs | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/crates/now-package-broker/src/pipe.rs b/crates/now-package-broker/src/pipe.rs index 6ecab0eec..835846944 100644 --- a/crates/now-package-broker/src/pipe.rs +++ b/crates/now-package-broker/src/pipe.rs @@ -107,6 +107,7 @@ async fn accept_connections( match result { Ok(()) => { let state = Arc::clone(state); + let connection_deadline = tokio::time::Instant::now() + CONNECTION_DEADLINE; connections.spawn(async move { // Serving this connection can commit a policy and record its terminal // event, which is blocking work the shutdown cannot interrupt, so the @@ -120,7 +121,7 @@ async fn accept_connections( (server, client) }); let (_permit, server, client) = - match tokio::time::timeout(CONNECTION_DEADLINE, capture).await { + match tokio::time::timeout_at(connection_deadline, capture).await { Ok(Ok((permit, (server, Ok(client))))) => (permit, server, client), Ok(Ok((_permit, (_server, Err(error))))) => { warn!(error = format!("{error:#}"), "Rejected named pipe client"); @@ -148,25 +149,23 @@ async fn accept_connections( ); let serve = serve_connection(server, router); tokio::pin!(serve); - let connection_deadline = tokio::time::sleep(CONNECTION_DEADLINE); - tokio::pin!(connection_deadline); - let policy_deadline = tokio::time::sleep(POLICY_CONSENT_CONNECTION_DEADLINE); - tokio::pin!(policy_deadline); let mut policy_write_is_authorized = false; + let mut deadline = connection_deadline; loop { tokio::select! { () = &mut serve => break, - () = &mut connection_deadline, if !policy_write_is_authorized => { - warn!("Closed named pipe connection: deadline exceeded"); - break; - } - () = &mut policy_deadline, if policy_write_is_authorized => { - warn!("Closed named pipe policy replacement: deadline exceeded"); + () = tokio::time::sleep_until(deadline) => { + if policy_write_is_authorized { + warn!("Closed named pipe policy replacement: deadline exceeded"); + } else { + warn!("Closed named pipe connection: deadline exceeded"); + } break; } result = policy_write_authorized.changed(), if !policy_write_is_authorized => { if result.is_ok() && *policy_write_authorized.borrow_and_update() { policy_write_is_authorized = true; + deadline = tokio::time::Instant::now() + POLICY_CONSENT_CONNECTION_DEADLINE; } } } From 59289e74d4bf1f366cea695b9b9bddc88039720b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 06:48:31 +0900 Subject: [PATCH 34/39] fix(agent): reject padded policy sources Reject noncanonical source spellings before policy matching so PowerShell repository trimming cannot bypass a source-specific rule. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/evaluator/mod.rs | 2 +- crates/now-package-broker/src/evaluator/tests.rs | 2 ++ crates/now-package-broker/src/server/mod.rs | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/now-package-broker/src/evaluator/mod.rs b/crates/now-package-broker/src/evaluator/mod.rs index 9d09208c5..729c8abc6 100644 --- a/crates/now-package-broker/src/evaluator/mod.rs +++ b/crates/now-package-broker/src/evaluator/mod.rs @@ -116,7 +116,7 @@ pub fn evaluate(policy: &PolicyDocument, request: &PackageRequest) -> PolicyDeci /// Whether a source spelling has a stable identity across package-manager lookup and /// policy evaluation. pub(crate) fn source_name_is_unambiguous(source_name: &str) -> bool { - !wildcard::has_default_ignorable_code_point(source_name) + source_name == source_name.trim() && !wildcard::has_default_ignorable_code_point(source_name) } pub(crate) fn effective_execution_elevation(request: &PackageRequest) -> Elevation { diff --git a/crates/now-package-broker/src/evaluator/tests.rs b/crates/now-package-broker/src/evaluator/tests.rs index ec0993278..3e845a5e3 100644 --- a/crates/now-package-broker/src/evaluator/tests.rs +++ b/crates/now-package-broker/src/evaluator/tests.rs @@ -169,6 +169,8 @@ fn unicode_case_equivalent_source_deny_outranks_allow() { #[test] fn default_ignorable_source_spelling_is_rejected_before_evaluation() { assert!(!source_name_is_unambiguous("PS\u{00AD}Gallery")); + assert!(!source_name_is_unambiguous("PSGallery ")); + assert!(!source_name_is_unambiguous(" PSGallery")); assert!(source_name_is_unambiguous("PSGallery")); } diff --git a/crates/now-package-broker/src/server/mod.rs b/crates/now-package-broker/src/server/mod.rs index 46fa5dfa5..0dcc9b9d3 100644 --- a/crates/now-package-broker/src/server/mod.rs +++ b/crates/now-package-broker/src/server/mod.rs @@ -757,11 +757,11 @@ impl BrokerState { if !evaluator::source_name_is_unambiguous(&request.source.name) { warn!( request_id = %request.request_id, - "Rejecting request: package source name contains default-ignorable characters" + "Rejecting request: package source name has ambiguous spelling" ); return Err(error_response( ErrorCode::ValidationFailed, - "package source name contains unsupported default-ignorable characters", + "package source name has unsupported leading, trailing, or default-ignorable characters", )); } From 5715f196a1385181f0f67172462845fd0ee04024 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 06:57:41 +0900 Subject: [PATCH 35/39] fix(agent): preserve manager source identity Apply PowerShell source canonicalization only to PowerShell so other package managers retain their own source identity semantics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/evaluator/matching.rs | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/crates/now-package-broker/src/evaluator/matching.rs b/crates/now-package-broker/src/evaluator/matching.rs index 782070c21..3bd0dbf80 100644 --- a/crates/now-package-broker/src/evaluator/matching.rs +++ b/crates/now-package-broker/src/evaluator/matching.rs @@ -21,7 +21,7 @@ pub(super) fn rule_matches( operations_match(request.operation, &m.operations) && managers_match(request.manager, &m.managers) - && source_names_match(&request.source.name, &m.source_names) + && source_names_match(request.manager, &request.source.name, &m.source_names) && package_identifiers_match(&request.package.id, &m.package_identifiers) && versions_match(effective_version, &m.version) && scopes_match(request.options.scope, &m.scopes) @@ -129,11 +129,18 @@ fn elevation_match(elevation: now_policy_api::Elevation, allowed: &BTreeSet) -> bool { +fn source_names_match( + manager: now_policy_api::ManagerName, + value: &str, + allowed: &BTreeSet, +) -> bool { allowed.is_empty() - || allowed - .iter() - .any(|source| literal_case_insensitive_match(value, source.as_ref())) + || allowed.iter().any(|source| match manager { + now_policy_api::ManagerName::PowerShell | now_policy_api::ManagerName::PowerShell7 => { + literal_case_insensitive_match(value, source.as_ref()) + } + _ => source.as_ref().eq_ignore_ascii_case(value), + }) } fn package_identifiers_match( @@ -265,6 +272,20 @@ mod tests { })); } + #[test] + fn non_powershell_source_names_remain_canonically_distinct() { + let mut request = request(); + request.manager = api::ManagerName::Scoop; + request.source.name = "CO\u{0308}RP".to_owned(); + let flags = RequestFlags::from_request(&request); + let rule = rule(PolicyMatch { + source_names: BTreeSet::from([now_policy::SourceName::parse("CÖRP").expect("valid source")]), + ..Default::default() + }); + + assert!(!rule_matches(&rule, &request, &flags, "1.2.3")); + } + #[test] fn absent_scope_or_architecture_in_request_fails_when_rule_restricts_them() { let mut request = request(); From f8cea81e5d66ef943acbc4b6d81f6d51a6d14268 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 07:17:31 +0900 Subject: [PATCH 36/39] fix(agent): validate policy source spelling Reject policy source spellings that cannot safely match package requests before they can create unusable source-specific rules. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/policy_store/validation.rs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/now-package-broker/src/policy_store/validation.rs b/crates/now-package-broker/src/policy_store/validation.rs index 2f0771679..362b0998e 100644 --- a/crates/now-package-broker/src/policy_store/validation.rs +++ b/crates/now-package-broker/src/policy_store/validation.rs @@ -10,6 +10,8 @@ use now_policy_api::{ API_VERSION_STR, PolicyFinding, PolicyFindingCode, PolicyFindingSeverity, PolicyValidationResult, }; +use crate::evaluator; + pub(super) const VALIDATOR_VERSION: &str = "now-package-broker-policy-validator/10"; const MAX_RULES: usize = 1024; const MAX_RULE_PRIORITY: u32 = i32::MAX as u32; @@ -419,6 +421,17 @@ fn check_rule(index: usize, rule: &PolicyRule, findings: &mut Findings) { if let Some(reason) = &rule.reason { check_string_len(reason, 0, 512, &format!("{base}/Reason"), findings); } + for (source_index, source_name) in rule.match_criteria.source_names.iter().enumerate() { + if !evaluator::source_name_is_unambiguous(source_name.as_ref()) { + findings.push(rule_finding( + rule, + PolicyFindingSeverity::Error, + PolicyFindingCode::InvalidFieldValue, + format!("{base}/Match/SourceNames/{source_index}"), + "SourceNames must not contain leading, trailing, or default-ignorable characters", + )); + } + } if let Some(PackageIdentifierCondition::Patterns(patterns)) = &rule.match_criteria.package_identifiers { check_patterns( index, @@ -709,6 +722,18 @@ mod tests { assert_eq!(canonical.pointer("/Rules/0/Match/Interactive"), Some(&json!(false))); } + #[test] + fn source_names_reject_ambiguous_spellings() { + for source_name in ["PSGallery ", " PSGallery", "PS\u{00AD}Gallery"] { + let mut raw = draft(); + raw["Rules"] = json!([rule("deny", json!({ "SourceNames": [source_name] }))]); + + let result = validate_draft(&raw); + + assert!(!result.is_valid, "{source_name:?} must be rejected"); + } + } + #[test] fn shared_contract_rejects_invalid_rule_shapes() { let cases = [ From cc30252773a058c2a938a3605ed8460b6d49be9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 07:20:48 +0900 Subject: [PATCH 37/39] test(agent): cover policy source spelling guard Exercise ambiguous SourceNames with a valid PowerShell rule so the regression protects the shared policy-validation predicate. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/policy_store/validation.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/now-package-broker/src/policy_store/validation.rs b/crates/now-package-broker/src/policy_store/validation.rs index 362b0998e..c24cf8da7 100644 --- a/crates/now-package-broker/src/policy_store/validation.rs +++ b/crates/now-package-broker/src/policy_store/validation.rs @@ -726,12 +726,28 @@ mod tests { fn source_names_reject_ambiguous_spellings() { for source_name in ["PSGallery ", " PSGallery", "PS\u{00AD}Gallery"] { let mut raw = draft(); - raw["Rules"] = json!([rule("deny", json!({ "SourceNames": [source_name] }))]); + raw["Rules"] = json!([rule( + "deny", + json!({ "Managers": ["PowerShell"], "SourceNames": [source_name] }) + )]); let result = validate_draft(&raw); assert!(!result.is_valid, "{source_name:?} must be rejected"); + assert!( + result + .findings + .iter() + .any(|finding| finding.path == "/Rules/0/Match/SourceNames/0") + ); } + + let mut valid = draft(); + valid["Rules"] = json!([rule( + "deny", + json!({ "Managers": ["PowerShell"], "SourceNames": ["PSGallery"] }) + )]); + assert!(validate_draft(&valid).is_valid); } #[test] From 3239f2e2c39bfb8b880e7e1b1ef0e2e33a730c69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Fri, 18 Sep 2026 10:54:40 +0900 Subject: [PATCH 38/39] fix(agent,agent-installer): publish helper discovery in both views Publish consent-helper discovery and configured broker-pipe values to the 32-bit registry view so supported x86 consumers find the protected helper. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- devolutions-agent/src/service.rs | 22 ++++++++- .../PolicyConsentDiscoveryTests.cs | 26 ++++++++++ package/AgentWindowsManaged/Program.cs | 48 ++++++++++++++++++- 3 files changed, 94 insertions(+), 2 deletions(-) diff --git a/devolutions-agent/src/service.rs b/devolutions-agent/src/service.rs index 46a59f4fe..cd3f84b1d 100644 --- a/devolutions-agent/src/service.rs +++ b/devolutions-agent/src/service.rs @@ -31,6 +31,8 @@ pub(crate) const DESCRIPTION: &str = "Devolutions Agent service"; const POLICY_CONSENT_DISCOVERY_KEY: &str = r"SOFTWARE\Devolutions\Agent\PolicyConsentHelper"; #[cfg(windows)] const POLICY_CONSENT_BROKER_PIPE_NAME_VALUE: &str = "BrokerPipeName"; +#[cfg(all(windows, target_pointer_width = "64"))] +const KEY_WOW64_32KEY: u32 = 0x0200; struct TasksCtx { /// Spawned service tasks @@ -293,7 +295,25 @@ fn publish_policy_consent_broker_pipe_name(pipe_name: &str) -> anyhow::Result<() .write() .open(POLICY_CONSENT_DISCOVERY_KEY) .context("open policy consent helper discovery key")?; - publish_policy_consent_broker_pipe_name_to(&key, pipe_name) + publish_policy_consent_broker_pipe_name_to(&key, pipe_name)?; + + // A 32-bit UniGetUI process reads HKLM\Software through WOW6432Node, while the + // 64-bit Agent service naturally opens the native view. Keep configured pipe + // discovery synchronized with the MSI's dual-view contract. + #[cfg(target_pointer_width = "64")] + { + let wow64_key = LOCAL_MACHINE + .options() + .read() + .write() + .access(KEY_WOW64_32KEY) + .open(POLICY_CONSENT_DISCOVERY_KEY) + .context("open 32-bit policy consent helper discovery key")?; + publish_policy_consent_broker_pipe_name_to(&wow64_key, pipe_name) + .context("publish configured policy consent helper broker pipe to 32-bit registry view")?; + } + + Ok(()) } #[cfg(windows)] diff --git a/package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs b/package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs index f90219f71..c7c38d3a6 100644 --- a/package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs +++ b/package/AgentWindowsManaged.Tests/PolicyConsentDiscoveryTests.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Reflection; using WixSharp; @@ -41,6 +42,31 @@ public void DiscoveryUsesTheConsumerNativeRegistryView(Platform platform, bool e Assert.Equal(expected, Assert.IsType(method.Invoke(null, [platform]))); } + [Theory] + [InlineData(Platform.x86, 0)] + [InlineData(Platform.x64, 7)] + [InlineData(Platform.arm64, 7)] + public void NativeAgentMsiPublishesDiscoveryFor32BitConsumers(Platform platform, int expectedCount) + { + Type program = System.Reflection.Assembly + .Load("DevolutionsAgent") + .GetType("DevolutionsAgent.Program", throwOnError: true); + MethodInfo method = program.GetMethod( + "CreatePolicyConsentRegistryValuesFor32BitConsumers", + BindingFlags.Static | BindingFlags.NonPublic); + + RegValue[] values = Assert.IsAssignableFrom>( + method.Invoke(null, [platform, new Version(2026, 3, 0)])) + .ToArray(); + + Assert.Equal(expectedCount, values.Length); + Assert.All(values, value => + { + Assert.False(value.Win64); + Assert.Equal(RegistryKeyAction.createAndRemoveOnUninstall, value.RegistryKeyAction); + }); + } + [Fact] public void DiscoveryPublishesTheFixedHelperIdentity() { diff --git a/package/AgentWindowsManaged/Program.cs b/package/AgentWindowsManaged/Program.cs index 865c93f18..c3c17a7c1 100644 --- a/package/AgentWindowsManaged/Program.cs +++ b/package/AgentWindowsManaged/Program.cs @@ -393,6 +393,9 @@ static void Main() }, CreateEventLogSourceRegistryValue(project.Platform == Platform.x64), }; + project.RegValues = project.RegValues + .Concat(CreatePolicyConsentRegistryValuesFor32BitConsumers(project.Platform, DevolutionsAgentProductVersion)) + .ToArray(); List projectProperties = AgentProperties.Properties.Select(x => x.ToWixSharpProperty()).ToList(); @@ -493,10 +496,53 @@ internal static RegValue CreatePolicyConsentRegistryValue(string name, string va Feature = Features.AGENT_FEATURE, }; - // Discovery follows the consumer's native registry view. + // 64-bit Agent MSIs publish discovery in both native and WOW6432Node views so a + // 32-bit UniGetUI consumer can discover the same protected helper. internal static bool Use64BitRegistryView(Platform? platform) => platform is Platform.x64 or Platform.arm64; + internal static IEnumerable CreatePolicyConsentRegistryValuesFor32BitConsumers( + Platform? platform, + Version productVersion) + { + if (!Use64BitRegistryView(platform)) + { + return []; + } + + return + [ + CreatePolicyConsentRegistryValue( + "ProtocolVersion", + Includes.POLICY_CONSENT_PROTOCOL_VERSION, + false), + CreatePolicyConsentRegistryValue( + "ExecutableName", + Includes.POLICY_CONSENT_EXECUTABLE_NAME, + false), + CreatePolicyConsentRegistryValue( + "ExecutablePath", + $"[{AgentProperties.InstallDir}]{Includes.POLICY_CONSENT_EXECUTABLE_NAME}", + false), + CreatePolicyConsentRegistryValue( + "ProductName", + Includes.POLICY_CONSENT_PRODUCT_NAME, + false), + CreatePolicyConsentRegistryValue( + "ProductVersion", + productVersion.ToString(), + false), + CreatePolicyConsentRegistryValue( + "BrokerPipeName", + Includes.POLICY_CONSENT_DEFAULT_BROKER_PIPE_NAME, + false), + CreatePolicyConsentRegistryValue( + "CurrentUiSignerSpkiSha256", + Includes.POLICY_CONSENT_CURRENT_UI_SIGNER_SPKI_SHA256, + false), + ]; + } + private static void Project_UnhandledException(ExceptionEventArgs e) { string errorMessage = From 1145f0ce058c0516e01480592396641bba87156b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Sun, 20 Sep 2026 17:59:47 +0900 Subject: [PATCH 39/39] fix(agent): align helper policy contract Remove the obsolete warning acknowledgement field so helper replacement requests match the released policy API after the parent rebase. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/now-package-broker/src/server/mod.rs | 1 - .../AgentPolicyConsent.Tests/ProtocolTests.cs | 19 ++++++++----------- package/AgentPolicyConsent/BrokerClient.cs | 1 - package/AgentPolicyConsent/Protocol.cs | 1 - 4 files changed, 8 insertions(+), 14 deletions(-) diff --git a/crates/now-package-broker/src/server/mod.rs b/crates/now-package-broker/src/server/mod.rs index 0dcc9b9d3..9d5fcef8c 100644 --- a/crates/now-package-broker/src/server/mod.rs +++ b/crates/now-package-broker/src/server/mod.rs @@ -1129,7 +1129,6 @@ mod tests { "ExpectedStoreToken": state.policy_store.management_snapshot().store_token, "Operation": "Create", "ConflictHandling": "Reject", - "WarningsAcknowledged": false, "Draft": draft, "ValidationReceipt": validation.validation_receipt.expect("valid receipt"), }); diff --git a/package/AgentPolicyConsent.Tests/ProtocolTests.cs b/package/AgentPolicyConsent.Tests/ProtocolTests.cs index 389538c87..5ce35de96 100644 --- a/package/AgentPolicyConsent.Tests/ProtocolTests.cs +++ b/package/AgentPolicyConsent.Tests/ProtocolTests.cs @@ -311,7 +311,7 @@ await Assert.ThrowsAnyAsync( public void RequestRejectsUnknownJsonMembers() { string json = - $$"""{"protocolVersion":"2.0","requestId":"{{RequestId}}","operation":"Update","conflictHandling":"Reject","expectedStoreToken":"a","validationReceipt":"b","warningsAcknowledged":false,"draft":{},"command":"cmd.exe"}"""; + $$"""{"protocolVersion":"2.0","requestId":"{{RequestId}}","operation":"Update","conflictHandling":"Reject","expectedStoreToken":"a","validationReceipt":"b","draft":{},"command":"cmd.exe"}"""; Assert.Throws( () => JsonSerializer.Deserialize(json, ProtocolJsonContext.Default.ElevationRequest)); @@ -328,7 +328,6 @@ public void OfficialRequestContainsOnlyPolicyReplacementFields() "ConfirmOverwrite", "token", "receipt", - true, draft.RootElement.Clone()); using JsonDocument official = JsonDocument.Parse(BrokerClient.CreateOfficialRequest(request)); @@ -340,22 +339,22 @@ public void OfficialRequestContainsOnlyPolicyReplacementFields() "ExpectedStoreToken", "Operation", "ConflictHandling", - "WarningsAcknowledged", "Draft", "ValidationReceipt", ], names); } [Fact] - public void RequestRequiresEveryMemberAndObjectDraft() + public void RequestRejectsLegacyAcknowledgementMember() { - string missingAcknowledgement = - $$$"""{"protocolVersion":"2.0","requestId":"{{{RequestId}}}","operation":"Update","conflictHandling":"Reject","expectedStoreToken":"a","validationReceipt":"b","draft":{}}"""; + string legacyAcknowledgement = string.Concat("Warnings", "Acknowledged"); + string requestJson = + $$"""{"protocolVersion":"2.0","requestId":"{{RequestId}}","operation":"Update","conflictHandling":"Reject","expectedStoreToken":"a","validationReceipt":"b","draft":{},"{{legacyAcknowledgement}}":false}"""; Assert.Throws( - () => JsonSerializer.Deserialize(missingAcknowledgement, ProtocolJsonContext.Default.ElevationRequest)); + () => JsonSerializer.Deserialize(requestJson, ProtocolJsonContext.Default.ElevationRequest)); using JsonDocument draft = JsonDocument.Parse("[]"); - ElevationRequest request = new("2.0", RequestId, "Update", "Reject", "a", "b", false, draft.RootElement); + ElevationRequest request = new("2.0", RequestId, "Update", "Reject", "a", "b", draft.RootElement); Assert.Throws(() => Protocol.ValidateRequest(request)); } @@ -363,7 +362,7 @@ public void RequestRequiresEveryMemberAndObjectDraft() public void RequestRejectsExplicitNullRequiredMembers() { string nullRequestId = - """{"protocolVersion":"2.0","requestId":null,"operation":"Update","conflictHandling":"Reject","expectedStoreToken":"a","validationReceipt":"b","warningsAcknowledged":false,"draft":{}}"""; + """{"protocolVersion":"2.0","requestId":null,"operation":"Update","conflictHandling":"Reject","expectedStoreToken":"a","validationReceipt":"b","draft":{}}"""; ElevationRequest request = JsonSerializer.Deserialize( nullRequestId, @@ -400,7 +399,6 @@ public async Task OversizedOfficialRequestIsRejectedBeforeBrokerConnection() "Reject", "token", "receipt", - false, draft.RootElement.Clone()); ElevationResponse response = await BrokerClient.ReplaceAsync(request, CancellationToken.None); @@ -422,7 +420,6 @@ public void RequestRejectsUnknownOperations(string operation, string conflictHan conflictHandling, "a", "b", - false, draft.RootElement); Assert.Throws(() => Protocol.ValidateRequest(request)); diff --git a/package/AgentPolicyConsent/BrokerClient.cs b/package/AgentPolicyConsent/BrokerClient.cs index c2594e4b0..ac6c0773f 100644 --- a/package/AgentPolicyConsent/BrokerClient.cs +++ b/package/AgentPolicyConsent/BrokerClient.cs @@ -106,7 +106,6 @@ internal static byte[] CreateOfficialRequest(ElevationRequest request) writer.WriteString("ExpectedStoreToken", request.ExpectedStoreToken); writer.WriteString("Operation", request.Operation); writer.WriteString("ConflictHandling", request.ConflictHandling); - writer.WriteBoolean("WarningsAcknowledged", request.WarningsAcknowledged); writer.WritePropertyName("Draft"); request.Draft.WriteTo(writer); writer.WriteString("ValidationReceipt", request.ValidationReceipt); diff --git a/package/AgentPolicyConsent/Protocol.cs b/package/AgentPolicyConsent/Protocol.cs index dcf07ed21..e26288560 100644 --- a/package/AgentPolicyConsent/Protocol.cs +++ b/package/AgentPolicyConsent/Protocol.cs @@ -214,7 +214,6 @@ internal sealed record ElevationRequest( [property: JsonRequired] string ConflictHandling, [property: JsonRequired] string ExpectedStoreToken, [property: JsonRequired] string ValidationReceipt, - [property: JsonRequired] bool WarningsAcknowledged, [property: JsonRequired] JsonElement Draft); [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]