From 794d313a0b1888b2e55c0715acb261e8fe99c43d Mon Sep 17 00:00:00 2001 From: Vladyslav Nikonov Date: Tue, 28 Jul 2026 00:21:39 +0300 Subject: [PATCH 1/4] feat(agent): add Chocolatey broker support Add Chocolatey command construction, capabilities, and Windows executor hardening for the package broker. Issue: TBD Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/broker/command_builder/chocolatey.rs | 275 ++++++++++++++++++ .../src/broker/command_builder/mod.rs | 3 + .../src/broker/executor/windows/mod.rs | 221 +++++++++++++- .../src/broker/server/responses.rs | 11 + 4 files changed, 504 insertions(+), 6 deletions(-) create mode 100644 devolutions-agent/src/broker/command_builder/chocolatey.rs diff --git a/devolutions-agent/src/broker/command_builder/chocolatey.rs b/devolutions-agent/src/broker/command_builder/chocolatey.rs new file mode 100644 index 000000000..298274b07 --- /dev/null +++ b/devolutions-agent/src/broker/command_builder/chocolatey.rs @@ -0,0 +1,275 @@ +//! Chocolatey command-line builder. + +use anyhow::bail; +use now_policy_api::{Architecture, Operation, PackageRequest, Scope}; + +use super::set_if_true; + +/// Build the Chocolatey command line from a validated request. +/// +/// Returns the command as a list of arguments (first element is the executable). +pub fn build_chocolatey_command(request: &PackageRequest) -> anyhow::Result> { + validate_chocolatey_request(request)?; + + let operation = match request.operation { + Operation::Install => "install", + Operation::Update => "upgrade", + Operation::Uninstall => "uninstall", + }; + + let mut command = vec![ + "choco.exe".to_owned(), + operation.to_owned(), + request.package.id.0.clone(), + "-y".to_owned(), + ]; + + set_if_true(&mut command, "--notsilent", request.options.interactive); + + if matches!(request.operation, Operation::Install | Operation::Update) { + command.push("--no-progress".to_owned()); + command.push("--source".to_owned()); + command.push(chocolatey_source(request)?.to_owned()); + + if request.package.architecture == Some(Architecture::X86) { + command.push("--forcex86".to_owned()); + } + + set_if_true(&mut command, "--prerelease", request.options.pre_release); + + if request.options.skip_hash_check { + command.push("--ignore-checksums".to_owned()); + command.push("--force".to_owned()); + } + + if let Some(version) = request.package.version.as_deref() { + command.push(format!("--version={version}")); + command.push("--allow-downgrade".to_owned()); + } + } + + Ok(command) +} + +fn validate_chocolatey_request(request: &PackageRequest) -> anyhow::Result<()> { + if request.package.channel.is_some() { + bail!("Chocolatey package channels are not supported by the broker"); + } + + if request.options.scope == Some(Scope::User) { + bail!("Chocolatey user scope is not supported by the broker"); + } + + if matches!( + request.package.architecture, + Some(Architecture::Arm64 | Architecture::Neutral) + ) { + bail!("Chocolatey only supports native x64/default or explicit x86 architecture selection"); + } + + if request + .options + .custom_install_location + .as_deref() + .is_some_and(|location| !location.is_empty()) + { + bail!("Chocolatey custom install locations are not supported by the broker"); + } + + if let Some(param) = request.options.custom_parameters.iter().find(|param| !param.is_empty()) { + bail!( + "Chocolatey custom parameters are not supported by the broker: {}", + param.0 + ); + } + + if request.options.no_upgrade { + bail!("Chocolatey no-upgrade requests are not supported by the broker"); + } + + if request.options.uninstall_previous { + bail!("Chocolatey uninstall-previous requests are not supported by the broker"); + } + + if matches!(request.operation, Operation::Uninstall) { + if request.options.skip_hash_check { + bail!("Chocolatey skip-hash-check is not supported for uninstall operations"); + } + if request.options.pre_release { + bail!("Chocolatey prerelease selection is not supported for uninstall operations"); + } + if request.package.architecture.is_some() { + bail!("Chocolatey architecture selection is not supported for uninstall operations"); + } + } + + if matches!(request.operation, Operation::Install | Operation::Update) { + chocolatey_source(request)?; + } + + Ok(()) +} + +fn chocolatey_source(request: &PackageRequest) -> anyhow::Result<&str> { + let source = request.source.url.as_deref().unwrap_or(&request.source.name).trim(); + if source.is_empty() { + bail!("Chocolatey package source is required"); + } + Ok(source) +} + +#[cfg(test)] +mod tests { + use chrono::Utc; + use now_policy_api::*; + + use super::*; + + fn make_request() -> PackageRequest { + PackageRequest { + request_kind: PackageRequestKind, + request_version: API_VERSION_STR.into(), + request_id: ResourceId::from("req-choco-1"), + created_at: Utc::now(), + operation: Operation::Install, + manager: ManagerName::Chocolatey, + source: RequestSource { + name: "community".to_owned(), + url: None, + }, + package: RequestPackage { + id: PackageIdentifier::from("git".to_owned()), + version: Some(VersionString("2.48.1".to_owned())), + architecture: None, + channel: None, + }, + options: RequestOptions { + scope: Some(Scope::Machine), + interactive: false, + skip_hash_check: false, + pre_release: false, + custom_install_location: None, + custom_parameters: Vec::new(), + pre_operation_command: None, + post_operation_command: None, + kill_before_operation: Vec::new(), + uninstall_previous: false, + no_upgrade: false, + }, + client: ClientContext { + transport: Transport::HttpNamedPipe, + requested_elevation: Elevation::Elevated, + effective_user: "DOMAIN\\user".to_owned(), + client_executable_path: "C:\\Program Files\\Devolutions\\Package Broker\\PackageBrokerClient.exe" + .to_owned(), + client_version: "1.0.0".to_owned(), + }, + include_command_preview: false, + capture_output: false, + } + } + + #[test] + fn install_matches_chocolatey_semantics() { + let request = make_request(); + let cmd = build_chocolatey_command(&request).expect("build command"); + + assert_eq!( + cmd, + [ + "choco.exe", + "install", + "git", + "-y", + "--no-progress", + "--source", + "community", + "--version=2.48.1", + "--allow-downgrade" + ] + ); + } + + #[test] + fn update_supports_x86_prerelease_skip_hash_and_source_url() { + let mut request = make_request(); + request.operation = Operation::Update; + request.source.url = Some("https://community.chocolatey.org/api/v2/".to_owned()); + request.package.architecture = Some(Architecture::X86); + request.options.pre_release = true; + request.options.skip_hash_check = true; + request.options.interactive = true; + + let cmd = build_chocolatey_command(&request).expect("build command"); + + assert_eq!(cmd[1], "upgrade"); + assert!(cmd.contains(&"--notsilent".to_owned())); + assert!(cmd.contains(&"https://community.chocolatey.org/api/v2/".to_owned())); + assert!(cmd.contains(&"--forcex86".to_owned())); + assert!(cmd.contains(&"--prerelease".to_owned())); + assert!(cmd.contains(&"--ignore-checksums".to_owned())); + assert!(cmd.contains(&"--force".to_owned())); + } + + #[test] + fn install_accepts_explicit_x64_without_forcex86() { + let mut request = make_request(); + request.package.architecture = Some(Architecture::X64); + + let cmd = build_chocolatey_command(&request).expect("build command"); + + assert!(!cmd.contains(&"--forcex86".to_owned())); + } + + #[test] + fn uninstall_omits_install_only_options_and_version() { + let mut request = make_request(); + request.operation = Operation::Uninstall; + request.package.version = Some(VersionString("2.48.1".to_owned())); + request.options.interactive = true; + + let cmd = build_chocolatey_command(&request).expect("build command"); + + assert_eq!(cmd, ["choco.exe", "uninstall", "git", "-y", "--notsilent"]); + } + + #[test] + fn custom_parameters_are_rejected() { + let mut request = make_request(); + request.options.custom_parameters = vec![CustomParameterString("--allow-empty-checksums".to_owned())]; + + let error = build_chocolatey_command(&request).expect_err("custom parameter should fail"); + + assert!(error.to_string().contains("custom parameters")); + } + + #[test] + fn unsupported_security_sensitive_uninstall_options_are_rejected() { + let mut request = make_request(); + request.operation = Operation::Uninstall; + request.options.skip_hash_check = true; + + let error = build_chocolatey_command(&request).expect_err("skip hash should fail"); + + assert!(error.to_string().contains("skip-hash-check")); + } + + #[test] + fn unsupported_scope_architecture_and_location_are_rejected() { + let mut request = make_request(); + request.options.scope = Some(Scope::User); + assert!(build_chocolatey_command(&request).is_err()); + + request = make_request(); + request.package.architecture = Some(Architecture::Arm64); + assert!(build_chocolatey_command(&request).is_err()); + + request = make_request(); + request.package.architecture = Some(Architecture::Neutral); + assert!(build_chocolatey_command(&request).is_err()); + + request = make_request(); + request.options.custom_install_location = Some("C:\\Tools".to_owned()); + assert!(build_chocolatey_command(&request).is_err()); + } +} diff --git a/devolutions-agent/src/broker/command_builder/mod.rs b/devolutions-agent/src/broker/command_builder/mod.rs index f1706fb18..a5e36b12a 100644 --- a/devolutions-agent/src/broker/command_builder/mod.rs +++ b/devolutions-agent/src/broker/command_builder/mod.rs @@ -3,6 +3,7 @@ //! Constructs the commands the broker would execute from validated request fields. //! The broker never executes client-supplied commands directly. +pub mod chocolatey; pub mod powershell; pub mod winget; @@ -17,6 +18,8 @@ pub fn build_command(request: &PackageRequest) -> anyhow::Result> { ManagerName::Winget => Ok(winget::build_winget_command(request)), ManagerName::PowerShell => powershell::build_powershell5_command(request), ManagerName::PowerShell7 => powershell::build_powershell7_command(request), + ManagerName::Chocolatey => chocolatey::build_chocolatey_command(request), + unsupported => anyhow::bail!("package manager is not supported by the broker: {unsupported}"), } } diff --git a/devolutions-agent/src/broker/executor/windows/mod.rs b/devolutions-agent/src/broker/executor/windows/mod.rs index 867aab6b3..f972b5cae 100644 --- a/devolutions-agent/src/broker/executor/windows/mod.rs +++ b/devolutions-agent/src/broker/executor/windows/mod.rs @@ -3,6 +3,7 @@ //! Uses a unified `CreateProcessAsUserW` code path for both SYSTEM (service) and //! current-user (development) modes. +use std::collections::HashMap; use std::path::{Path, PathBuf}; use anyhow::{Context as _, bail}; @@ -257,7 +258,7 @@ fn prepare_main_command(token: &Token, command: &[String]) -> anyhow::Result
,
-    user_env: Option<&std::collections::HashMap>,
+    user_env: Option<&HashMap>,
 ) -> anyhow::Result {
     if let Some(script) = powershell_inline_script(command) {
         return prepare_powershell_script(command, script, temp_dir);
@@ -267,6 +268,10 @@ fn prepare_main_command_in(
         return prepare_winget_script(command, temp_dir, user_env);
     }
 
+    if executable_is(command, "choco.exe") {
+        return prepare_chocolatey_script(command, temp_dir, user_env);
+    }
+
     Ok(PreparedCommand::raw(command))
 }
 
@@ -343,7 +348,7 @@ fn powershell_script_with_utf8_preamble(script: &str) -> String {
 fn prepare_winget_script(
     command: &[String],
     temp_dir: Option<&Path>,
-    user_env: Option<&std::collections::HashMap>,
+    user_env: Option<&HashMap>,
 ) -> anyhow::Result {
     let mut script = String::new();
     script.push_str("@echo off\r\n");
@@ -382,6 +387,55 @@ fn prepare_winget_script(
     Ok(PreparedCommand::with_script(prepared, temp_script))
 }
 
+fn prepare_chocolatey_script(
+    command: &[String],
+    temp_dir: Option<&Path>,
+    user_env: Option<&HashMap>,
+) -> anyhow::Result {
+    prepare_chocolatey_script_in(command, temp_dir, user_env)
+}
+
+fn prepare_chocolatey_script_in(
+    command: &[String],
+    temp_dir: Option<&Path>,
+    user_env: Option<&HashMap>,
+) -> anyhow::Result {
+    let mut script = String::new();
+    script.push_str("@echo off\r\n");
+    script.push_str(BATCH_UTF8_PREAMBLE);
+    script.push_str("\r\nset \"NO_COLOR=1\"\r\n");
+
+    let (_executable, args) = command.split_first().context("empty Chocolatey command")?;
+    let (executable, install_root) = resolve_trusted_chocolatey_executable(user_env)?;
+    append_batch_set_value(&mut script, "ChocolateyInstall", &install_root.display().to_string())?;
+    script.push_str("\r\n");
+    append_batch_argument(&mut script, &executable.display().to_string())?;
+    for arg in args {
+        script.push(' ');
+        append_batch_argument(&mut script, arg)?;
+    }
+    script.push_str("\r\nexit /b %ERRORLEVEL%\r\n");
+
+    let temp_script = broker_temp_script("bat", temp_dir)?;
+    temp_script.write_content(&script).with_context(|| {
+        format!(
+            "failed to write broker temporary script at {}",
+            temp_script.path().display()
+        )
+    })?;
+
+    let prepared = vec![
+        trusted_system32_executable("cmd.exe"),
+        "/D".to_owned(),
+        "/V:OFF".to_owned(),
+        "/Q".to_owned(),
+        "/C".to_owned(),
+        temp_script.path_string(),
+    ];
+
+    Ok(PreparedCommand::with_script(prepared, temp_script))
+}
+
 fn powershell_inline_script(command: &[String]) -> Option<&str> {
     if command.len() == 4
         && (executable_is(command, "powershell.exe") || executable_is(command, "pwsh.exe"))
@@ -540,7 +594,61 @@ fn trusted_powershell7_executable() -> String {
         .to_string()
 }
 
-fn resolve_winget_executable(env: &std::collections::HashMap) -> anyhow::Result {
+fn program_data_dir(user_env: Option<&HashMap>) -> PathBuf {
+    user_env
+        .and_then(|env| env_value_ignore_case(env, "ProgramData"))
+        .filter(|value| !value.trim().is_empty())
+        .map_or_else(|| PathBuf::from(r"C:\ProgramData"), PathBuf::from)
+}
+
+fn default_chocolatey_install_dir(user_env: Option<&HashMap>) -> PathBuf {
+    program_data_dir(user_env).join("chocolatey")
+}
+
+fn resolve_trusted_chocolatey_executable(
+    user_env: Option<&HashMap>,
+) -> anyhow::Result<(PathBuf, PathBuf)> {
+    let install_root = user_env
+        .and_then(|env| env_value_ignore_case(env, "ChocolateyInstall"))
+        .filter(|value| !value.trim().is_empty())
+        .map_or_else(|| default_chocolatey_install_dir(user_env), PathBuf::from);
+    let executable = install_root.join("bin").join("choco.exe");
+    if !executable.is_file() {
+        bail!(
+            "Chocolatey executable was not found at {}; set ChocolateyInstall to the Chocolatey installation root",
+            executable.display()
+        );
+    }
+
+    if !is_trusted_chocolatey_install_dir(&install_root, user_env) {
+        bail!(
+            "ChocolateyInstall points to {}; only the trusted system Chocolatey folder at {} is supported by the broker",
+            install_root.display(),
+            default_chocolatey_install_dir(user_env).display()
+        );
+    }
+
+    Ok((executable, install_root))
+}
+
+fn is_trusted_chocolatey_install_dir(install_root: &Path, user_env: Option<&HashMap>) -> bool {
+    paths_eq_ignore_ascii_case(install_root, &default_chocolatey_install_dir(user_env))
+}
+
+fn paths_eq_ignore_ascii_case(lhs: &Path, rhs: &Path) -> bool {
+    lhs.as_os_str()
+        .to_string_lossy()
+        .trim_end_matches(['\\', '/'])
+        .eq_ignore_ascii_case(rhs.as_os_str().to_string_lossy().trim_end_matches(['\\', '/']))
+}
+
+fn env_value_ignore_case<'a>(env: &'a HashMap, key: &str) -> Option<&'a str> {
+    env.iter()
+        .find(|(candidate, _)| candidate.eq_ignore_ascii_case(key))
+        .map(|(_, value)| value.as_str())
+}
+
+fn resolve_winget_executable(env: &HashMap) -> anyhow::Result {
     let path_var = env
         .iter()
         .find(|(key, _)| key.eq_ignore_ascii_case("PATH"))
@@ -555,7 +663,7 @@ fn resolve_winget_executable(env: &std::collections::HashMap) ->
     bail!("trusted winget.exe not found in target user PATH");
 }
 
-fn is_trusted_winget_path(candidate: &Path, env: &std::collections::HashMap) -> bool {
+fn is_trusted_winget_path(candidate: &Path, env: &HashMap) -> bool {
     let candidate = candidate.as_os_str().to_string_lossy().to_lowercase();
     let program_files = env
         .iter()
@@ -574,7 +682,7 @@ fn is_trusted_winget_path(candidate: &Path, env: &std::collections::HashMap anyhow::Result<()> {
     if value.contains(['\0', '\r', '\n']) {
-        bail!("winget command arguments cannot contain control line separators");
+        bail!("package manager command arguments cannot contain control line separators");
     }
 
     script.push('"');
@@ -615,6 +723,20 @@ fn append_batch_argument(script: &mut String, value: &str) -> anyhow::Result<()>
     Ok(())
 }
 
+fn append_batch_set_value(script: &mut String, name: &str, value: &str) -> anyhow::Result<()> {
+    if value.contains(['\0', '\r', '\n', '"']) {
+        bail!("batch environment values cannot contain control line separators or quotes");
+    }
+
+    script.push_str("set \"");
+    script.push_str(name);
+    script.push('=');
+    script.push_str(&value.replace('%', "%%"));
+    script.push('"');
+
+    Ok(())
+}
+
 struct PreparedCommand {
     command: Vec,
     _script: Option,
@@ -642,7 +764,12 @@ impl PreparedCommand {
 
 #[cfg(test)]
 mod tests {
-    use super::{POWERSHELL_UTF8_ENCODING_PREAMBLE, prepare_main_command_in, prepare_shell_command_in};
+    use std::collections::HashMap;
+
+    use super::{
+        POWERSHELL_UTF8_ENCODING_PREAMBLE, prepare_chocolatey_script_in, prepare_main_command_in,
+        prepare_shell_command_in,
+    };
 
     #[test]
     fn shell_command_uses_utf8_temp_batch_file() {
@@ -850,4 +977,86 @@ mod tests {
 
         sddl_string
     }
+
+    #[test]
+    fn chocolatey_command_uses_chocolateyinstall_path_and_batch_wrapper() {
+        let temp_dir = tempfile::tempdir().expect("create temp dir");
+        let program_data = tempfile::tempdir().expect("create fake ProgramData");
+        let install_root = program_data.path().join("chocolatey");
+        let choco_bin = install_root.join("bin");
+        std::fs::create_dir_all(&choco_bin).expect("create fake Chocolatey bin");
+        std::fs::write(choco_bin.join("choco.exe"), "").expect("create fake choco");
+        let env = HashMap::from([
+            ("ChocolateyInstall".to_owned(), install_root.display().to_string()),
+            ("ProgramData".to_owned(), program_data.path().display().to_string()),
+        ]);
+
+        let command = vec![
+            "choco.exe".to_owned(),
+            "install".to_owned(),
+            "Vendor.Package&Name".to_owned(),
+            "100%".to_owned(),
+            "Quoted\"Value".to_owned(),
+        ];
+        let command = prepare_chocolatey_script_in(&command, Some(temp_dir.path()), Some(&env))
+            .expect("prepare Chocolatey command");
+
+        assert!(command.args()[0].ends_with(r"\System32\cmd.exe"));
+        assert_eq!(command.args()[1], "/D");
+        assert_eq!(command.args()[2], "/V:OFF");
+        assert_eq!(command.args()[3], "/Q");
+        assert_eq!(command.args()[4], "/C");
+
+        let script = std::fs::read_to_string(&command.args()[5]).expect("read temp script");
+        assert!(script.starts_with("@echo off\r\n@chcp 65001 > nul\r\nset \"NO_COLOR=1\""));
+        assert!(script.contains(&format!("set \"ChocolateyInstall={}\"", install_root.display())));
+        assert!(script.contains(&format!(
+            "\"{}\" \"install\" \"Vendor.Package&Name\" \"100%%\" \"Quoted\\\"Value\"",
+            choco_bin.join("choco.exe").display()
+        )));
+        assert!(script.contains("exit /b %ERRORLEVEL%"));
+    }
+
+    #[test]
+    fn chocolatey_command_rejects_missing_chocolateyinstall_executable() {
+        let temp_dir = tempfile::tempdir().expect("create temp dir");
+        let program_data = tempfile::tempdir().expect("create fake ProgramData");
+        let install_root = program_data.path().join("chocolatey");
+        let env = HashMap::from([
+            ("ChocolateyInstall".to_owned(), install_root.display().to_string()),
+            ("ProgramData".to_owned(), program_data.path().display().to_string()),
+        ]);
+        let command = vec!["choco.exe".to_owned(), "install".to_owned(), "git".to_owned()];
+
+        let error = match prepare_chocolatey_script_in(&command, Some(temp_dir.path()), Some(&env)) {
+            Ok(_) => panic!("missing trusted choco should fail"),
+            Err(error) => error,
+        };
+
+        assert!(error.to_string().contains("Chocolatey executable was not found"));
+    }
+
+    #[test]
+    fn chocolatey_command_rejects_non_system_chocolateyinstall_path() {
+        let temp_dir = tempfile::tempdir().expect("create temp dir");
+        let install_root = tempfile::tempdir().expect("create fake ChocolateyInstall");
+        let choco_bin = install_root.path().join("bin");
+        std::fs::create_dir_all(&choco_bin).expect("create fake Chocolatey bin");
+        std::fs::write(choco_bin.join("choco.exe"), "").expect("create fake choco");
+        let program_data = tempfile::tempdir().expect("create fake ProgramData");
+        let env = HashMap::from([
+            (
+                "ChocolateyInstall".to_owned(),
+                install_root.path().display().to_string(),
+            ),
+            ("ProgramData".to_owned(), program_data.path().display().to_string()),
+        ]);
+        let command = vec!["choco.exe".to_owned(), "install".to_owned(), "git".to_owned()];
+
+        let error = match prepare_chocolatey_script_in(&command, Some(temp_dir.path()), Some(&env)) {
+            Ok(_) => panic!("non-system ChocolateyInstall should fail"),
+            Err(error) => error,
+        };
+        assert!(error.to_string().contains("trusted system Chocolatey folder"));
+    }
 }
diff --git a/devolutions-agent/src/broker/server/responses.rs b/devolutions-agent/src/broker/server/responses.rs
index 1b25e012f..f337ca131 100644
--- a/devolutions-agent/src/broker/server/responses.rs
+++ b/devolutions-agent/src/broker/server/responses.rs
@@ -39,6 +39,17 @@ pub(super) fn default_manager_capabilities() -> Vec {
             supports_details: false,
             max_operation_timeout_seconds: Some(OperationTracker::operation_timeout().as_secs()),
         },
+        ManagerCapability {
+            manager: ManagerName::Chocolatey,
+            operations: vec![Operation::Install, Operation::Update, Operation::Uninstall],
+            scopes: vec![Scope::Machine],
+            architectures: vec![Architecture::X86, Architecture::X64],
+            supports_custom_parameters: false,
+            supports_custom_install_location: false,
+            supports_capture_output: true,
+            supports_details: false,
+            max_operation_timeout_seconds: Some(OperationTracker::operation_timeout().as_secs()),
+        },
         ManagerCapability {
             manager: ManagerName::PowerShell,
             operations: vec![Operation::Install, Operation::Update, Operation::Uninstall],

From 016cc27930d05c3433c4b323fb07af88e2dfe45f Mon Sep 17 00:00:00 2001
From: Vladyslav Nikonov 
Date: Tue, 28 Jul 2026 12:22:09 +0300
Subject: [PATCH 2/4] build(agent): use released now-policy v0.2 crates

Remove temporary local protocol patches and update the agent policy dependencies to the released v0.2 crates.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
---
 Cargo.lock                   | 20 ++++++++++----------
 devolutions-agent/Cargo.toml |  6 +++---
 2 files changed, 13 insertions(+), 13 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 9d6959700..2b250c01f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4714,9 +4714,9 @@ dependencies = [
 
 [[package]]
 name = "now-policy"
-version = "0.1.0"
+version = "0.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9d87d7ba63d8fcbbae125f975c5e30a54ed4507027dd70d6daa0aa63003cac2e"
+checksum = "2cc04b547209a37ecd3993eb47773ec6fa38f8cac178479079f650555bbedbbb"
 dependencies = [
  "chrono",
  "schemars",
@@ -4730,9 +4730,9 @@ dependencies = [
 
 [[package]]
 name = "now-policy-api"
-version = "0.1.0"
+version = "0.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b3837290f7ed48038a8ca4d9dfb98a0c44a5debe141cdd7d740d59eca4df4fa5"
+checksum = "ca52de7d57fca9ea49cc10f229bdc31cfbdb37f7d49a6e7407fa60248d1df7c6"
 dependencies = [
  "base64 0.22.1",
  "chrono",
@@ -4748,9 +4748,9 @@ dependencies = [
 
 [[package]]
 name = "now-policy-server-template"
-version = "0.1.0"
+version = "0.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "92afa468bf18b66af5d209caac39fcff241b07495c538d032221736e8a7f95c6"
+checksum = "88ce816e494a5034becfe105f30611284431275c24bdad3a8e7ef71a7b31e6e1"
 dependencies = [
  "aide",
  "async-trait",
@@ -7230,18 +7230,18 @@ dependencies = [
 
 [[package]]
 name = "strum"
-version = "0.27.2"
+version = "0.28.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
+checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
 dependencies = [
  "strum_macros",
 ]
 
 [[package]]
 name = "strum_macros"
-version = "0.27.2"
+version = "0.28.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
+checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
 dependencies = [
  "heck",
  "proc-macro2 1.0.106",
diff --git a/devolutions-agent/Cargo.toml b/devolutions-agent/Cargo.toml
index b7e61a0b7..e321cb67b 100644
--- a/devolutions-agent/Cargo.toml
+++ b/devolutions-agent/Cargo.toml
@@ -41,9 +41,9 @@ hyper-util = { version = "0.1", features = ["tokio", "server", "server-auto", "s
 http-client-proxy = { path = "../crates/http-client-proxy" }
 ipnetwork = "0.20"
 notify = { version = "7", default-features = false, features = ["macos_kqueue"] }
-now-policy = "0.1"
-now-policy-api = { version = "0.1", features = ["policy-compat"] }
-now-policy-server-template = { version = "0.1", features = ["policy-compat"] }
+now-policy = "0.2"
+now-policy-api = { version = "0.2", features = ["policy-compat"] }
+now-policy-server-template = { version = "0.2", features = ["policy-compat"] }
 parking_lot = "0.12"
 prost = "0.13"
 prost-types = "0.13"

From 4780a1c34cb5fce101672ac4d43b008762a9caa8 Mon Sep 17 00:00:00 2001
From: Vladyslav Nikonov 
Date: Tue, 28 Jul 2026 12:28:06 +0300
Subject: [PATCH 3/4] test(agent): restore broker auth client user helper

Add the missing test-only client user helper required by the shipping-build broker auth test after rebasing onto master.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
---
 devolutions-agent/src/broker/auth.rs | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/devolutions-agent/src/broker/auth.rs b/devolutions-agent/src/broker/auth.rs
index 06f322a07..e8cdc45a7 100644
--- a/devolutions-agent/src/broker/auth.rs
+++ b/devolutions-agent/src/broker/auth.rs
@@ -268,6 +268,11 @@ mod tests {
         }
     }
 
+    #[cfg(not(feature = "dev-skip-broker-signature"))]
+    fn client_user() -> ClientUser {
+        system_client().user
+    }
+
     #[test]
     fn resolve_account_sid_resolves_qualified_name() {
         let (domain, name) = system_account_names();

From 0867040914addecdfa9003a29ded63b71c63f65f Mon Sep 17 00:00:00 2001
From: Vladyslav Nikonov 
Date: Tue, 28 Jul 2026 12:49:15 +0300
Subject: [PATCH 4/4] fix(agent): address Chocolatey broker review findings

Reject Chocolatey source URLs, avoid force semantics for checksum bypass, resolve ProgramData from a trusted known folder, and normalize Chocolatey success exit codes.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
---
 devolutions-agent/Cargo.toml                  |  2 +
 .../src/broker/command_builder/chocolatey.rs  | 26 +++--
 .../src/broker/executor/windows/mod.rs        | 99 ++++++++++++++-----
 3 files changed, 94 insertions(+), 33 deletions(-)

diff --git a/devolutions-agent/Cargo.toml b/devolutions-agent/Cargo.toml
index e321cb67b..c57167482 100644
--- a/devolutions-agent/Cargo.toml
+++ b/devolutions-agent/Cargo.toml
@@ -120,6 +120,8 @@ features = [
     "Win32_System_ApplicationInstallationAndServicing",
     "Win32_System_Pipes",
     "Win32_System_RemoteDesktop",
+    "Win32_System_Com",
+    "Win32_UI_Shell",
 ]
 
 [target.'cfg(windows)'.build-dependencies]
diff --git a/devolutions-agent/src/broker/command_builder/chocolatey.rs b/devolutions-agent/src/broker/command_builder/chocolatey.rs
index 298274b07..09a0c933c 100644
--- a/devolutions-agent/src/broker/command_builder/chocolatey.rs
+++ b/devolutions-agent/src/broker/command_builder/chocolatey.rs
@@ -39,7 +39,6 @@ pub fn build_chocolatey_command(request: &PackageRequest) -> anyhow::Result anyhow::Result anyhow::Result<()> {
+    if request.source.url.is_some() {
+        bail!("Chocolatey package sources with URLs are not supported by the broker");
+    }
+
     if request.package.channel.is_some() {
         bail!("Chocolatey package channels are not supported by the broker");
     }
@@ -111,9 +114,9 @@ fn validate_chocolatey_request(request: &PackageRequest) -> anyhow::Result<()> {
 }
 
 fn chocolatey_source(request: &PackageRequest) -> anyhow::Result<&str> {
-    let source = request.source.url.as_deref().unwrap_or(&request.source.name).trim();
+    let source = request.source.name.trim();
     if source.is_empty() {
-        bail!("Chocolatey package source is required");
+        bail!("Chocolatey package source name is required");
     }
     Ok(source)
 }
@@ -191,10 +194,9 @@ mod tests {
     }
 
     #[test]
-    fn update_supports_x86_prerelease_skip_hash_and_source_url() {
+    fn update_supports_x86_prerelease_and_skip_hash() {
         let mut request = make_request();
         request.operation = Operation::Update;
-        request.source.url = Some("https://community.chocolatey.org/api/v2/".to_owned());
         request.package.architecture = Some(Architecture::X86);
         request.options.pre_release = true;
         request.options.skip_hash_check = true;
@@ -204,11 +206,11 @@ mod tests {
 
         assert_eq!(cmd[1], "upgrade");
         assert!(cmd.contains(&"--notsilent".to_owned()));
-        assert!(cmd.contains(&"https://community.chocolatey.org/api/v2/".to_owned()));
+        assert!(cmd.contains(&"community".to_owned()));
         assert!(cmd.contains(&"--forcex86".to_owned()));
         assert!(cmd.contains(&"--prerelease".to_owned()));
         assert!(cmd.contains(&"--ignore-checksums".to_owned()));
-        assert!(cmd.contains(&"--force".to_owned()));
+        assert!(!cmd.contains(&"--force".to_owned()));
     }
 
     #[test]
@@ -243,6 +245,16 @@ mod tests {
         assert!(error.to_string().contains("custom parameters"));
     }
 
+    #[test]
+    fn source_urls_are_rejected() {
+        let mut request = make_request();
+        request.source.url = Some("https://community.chocolatey.org/api/v2/".to_owned());
+
+        let error = build_chocolatey_command(&request).expect_err("source URL should fail");
+
+        assert!(error.to_string().contains("sources with URLs"));
+    }
+
     #[test]
     fn unsupported_security_sensitive_uninstall_options_are_rejected() {
         let mut request = make_request();
diff --git a/devolutions-agent/src/broker/executor/windows/mod.rs b/devolutions-agent/src/broker/executor/windows/mod.rs
index f972b5cae..e82423fc1 100644
--- a/devolutions-agent/src/broker/executor/windows/mod.rs
+++ b/devolutions-agent/src/broker/executor/windows/mod.rs
@@ -399,6 +399,16 @@ fn prepare_chocolatey_script_in(
     command: &[String],
     temp_dir: Option<&Path>,
     user_env: Option<&HashMap>,
+) -> anyhow::Result {
+    let default_install_root = default_chocolatey_install_dir()?;
+    prepare_chocolatey_script_in_with_default_install_root(command, temp_dir, user_env, &default_install_root)
+}
+
+fn prepare_chocolatey_script_in_with_default_install_root(
+    command: &[String],
+    temp_dir: Option<&Path>,
+    user_env: Option<&HashMap>,
+    default_install_root: &Path,
 ) -> anyhow::Result {
     let mut script = String::new();
     script.push_str("@echo off\r\n");
@@ -406,7 +416,7 @@ fn prepare_chocolatey_script_in(
     script.push_str("\r\nset \"NO_COLOR=1\"\r\n");
 
     let (_executable, args) = command.split_first().context("empty Chocolatey command")?;
-    let (executable, install_root) = resolve_trusted_chocolatey_executable(user_env)?;
+    let (executable, install_root) = resolve_trusted_chocolatey_executable(user_env, default_install_root)?;
     append_batch_set_value(&mut script, "ChocolateyInstall", &install_root.display().to_string())?;
     script.push_str("\r\n");
     append_batch_argument(&mut script, &executable.display().to_string())?;
@@ -414,7 +424,12 @@ fn prepare_chocolatey_script_in(
         script.push(' ');
         append_batch_argument(&mut script, arg)?;
     }
-    script.push_str("\r\nexit /b %ERRORLEVEL%\r\n");
+    script.push_str("\r\nset \"CHOCO_EXIT_CODE=%ERRORLEVEL%\"");
+    script.push_str("\r\nif \"%CHOCO_EXIT_CODE%\"==\"1605\" exit /b 0");
+    script.push_str("\r\nif \"%CHOCO_EXIT_CODE%\"==\"1614\" exit /b 0");
+    script.push_str("\r\nif \"%CHOCO_EXIT_CODE%\"==\"1641\" exit /b 0");
+    script.push_str("\r\nif \"%CHOCO_EXIT_CODE%\"==\"3010\" exit /b 0");
+    script.push_str("\r\nexit /b %CHOCO_EXIT_CODE%\r\n");
 
     let temp_script = broker_temp_script("bat", temp_dir)?;
     temp_script.write_content(&script).with_context(|| {
@@ -594,24 +609,35 @@ fn trusted_powershell7_executable() -> String {
         .to_string()
 }
 
-fn program_data_dir(user_env: Option<&HashMap>) -> PathBuf {
-    user_env
-        .and_then(|env| env_value_ignore_case(env, "ProgramData"))
-        .filter(|value| !value.trim().is_empty())
-        .map_or_else(|| PathBuf::from(r"C:\ProgramData"), PathBuf::from)
+fn system_program_data_dir() -> anyhow::Result {
+    use windows::Win32::System::Com::CoTaskMemFree;
+    use windows::Win32::UI::Shell::{FOLDERID_ProgramData, KF_FLAG_DEFAULT, SHGetKnownFolderPath};
+
+    // SAFETY: The known-folder ID is valid and no token handle is supplied.
+    let raw_path = unsafe { SHGetKnownFolderPath(&FOLDERID_ProgramData, KF_FLAG_DEFAULT, None) }
+        .context("failed to resolve system ProgramData folder")?;
+
+    // SAFETY: `SHGetKnownFolderPath` returns a valid null-terminated UTF-16 string on success.
+    let path = unsafe { raw_path.to_string() }.context("system ProgramData folder is not valid UTF-16")?;
+
+    // SAFETY: The returned string is allocated by the shell with `CoTaskMemAlloc`.
+    unsafe { CoTaskMemFree(Some(raw_path.as_ptr().cast())) };
+
+    Ok(PathBuf::from(path))
 }
 
-fn default_chocolatey_install_dir(user_env: Option<&HashMap>) -> PathBuf {
-    program_data_dir(user_env).join("chocolatey")
+fn default_chocolatey_install_dir() -> anyhow::Result {
+    Ok(system_program_data_dir()?.join("chocolatey"))
 }
 
 fn resolve_trusted_chocolatey_executable(
     user_env: Option<&HashMap>,
+    default_install_root: &Path,
 ) -> anyhow::Result<(PathBuf, PathBuf)> {
     let install_root = user_env
         .and_then(|env| env_value_ignore_case(env, "ChocolateyInstall"))
         .filter(|value| !value.trim().is_empty())
-        .map_or_else(|| default_chocolatey_install_dir(user_env), PathBuf::from);
+        .map_or_else(|| default_install_root.to_owned(), PathBuf::from);
     let executable = install_root.join("bin").join("choco.exe");
     if !executable.is_file() {
         bail!(
@@ -620,19 +646,19 @@ fn resolve_trusted_chocolatey_executable(
         );
     }
 
-    if !is_trusted_chocolatey_install_dir(&install_root, user_env) {
+    if !is_trusted_chocolatey_install_dir(&install_root, default_install_root) {
         bail!(
             "ChocolateyInstall points to {}; only the trusted system Chocolatey folder at {} is supported by the broker",
             install_root.display(),
-            default_chocolatey_install_dir(user_env).display()
+            default_install_root.display()
         );
     }
 
     Ok((executable, install_root))
 }
 
-fn is_trusted_chocolatey_install_dir(install_root: &Path, user_env: Option<&HashMap>) -> bool {
-    paths_eq_ignore_ascii_case(install_root, &default_chocolatey_install_dir(user_env))
+fn is_trusted_chocolatey_install_dir(install_root: &Path, default_install_root: &Path) -> bool {
+    paths_eq_ignore_ascii_case(install_root, default_install_root)
 }
 
 fn paths_eq_ignore_ascii_case(lhs: &Path, rhs: &Path) -> bool {
@@ -767,8 +793,8 @@ mod tests {
     use std::collections::HashMap;
 
     use super::{
-        POWERSHELL_UTF8_ENCODING_PREAMBLE, prepare_chocolatey_script_in, prepare_main_command_in,
-        prepare_shell_command_in,
+        POWERSHELL_UTF8_ENCODING_PREAMBLE, prepare_chocolatey_script_in_with_default_install_root,
+        prepare_main_command_in, prepare_shell_command_in,
     };
 
     #[test]
@@ -981,14 +1007,18 @@ mod tests {
     #[test]
     fn chocolatey_command_uses_chocolateyinstall_path_and_batch_wrapper() {
         let temp_dir = tempfile::tempdir().expect("create temp dir");
-        let program_data = tempfile::tempdir().expect("create fake ProgramData");
-        let install_root = program_data.path().join("chocolatey");
+        let trusted_program_data = tempfile::tempdir().expect("create fake trusted ProgramData");
+        let install_root = trusted_program_data.path().join("chocolatey");
         let choco_bin = install_root.join("bin");
         std::fs::create_dir_all(&choco_bin).expect("create fake Chocolatey bin");
         std::fs::write(choco_bin.join("choco.exe"), "").expect("create fake choco");
+        let untrusted_program_data = tempfile::tempdir().expect("create fake untrusted ProgramData");
         let env = HashMap::from([
             ("ChocolateyInstall".to_owned(), install_root.display().to_string()),
-            ("ProgramData".to_owned(), program_data.path().display().to_string()),
+            (
+                "ProgramData".to_owned(),
+                untrusted_program_data.path().display().to_string(),
+            ),
         ]);
 
         let command = vec![
@@ -998,8 +1028,13 @@ mod tests {
             "100%".to_owned(),
             "Quoted\"Value".to_owned(),
         ];
-        let command = prepare_chocolatey_script_in(&command, Some(temp_dir.path()), Some(&env))
-            .expect("prepare Chocolatey command");
+        let command = prepare_chocolatey_script_in_with_default_install_root(
+            &command,
+            Some(temp_dir.path()),
+            Some(&env),
+            &install_root,
+        )
+        .expect("prepare Chocolatey command");
 
         assert!(command.args()[0].ends_with(r"\System32\cmd.exe"));
         assert_eq!(command.args()[1], "/D");
@@ -1014,7 +1049,8 @@ mod tests {
             "\"{}\" \"install\" \"Vendor.Package&Name\" \"100%%\" \"Quoted\\\"Value\"",
             choco_bin.join("choco.exe").display()
         )));
-        assert!(script.contains("exit /b %ERRORLEVEL%"));
+        assert!(script.contains("if \"%CHOCO_EXIT_CODE%\"==\"3010\" exit /b 0"));
+        assert!(script.contains("exit /b %CHOCO_EXIT_CODE%"));
     }
 
     #[test]
@@ -1028,7 +1064,12 @@ mod tests {
         ]);
         let command = vec!["choco.exe".to_owned(), "install".to_owned(), "git".to_owned()];
 
-        let error = match prepare_chocolatey_script_in(&command, Some(temp_dir.path()), Some(&env)) {
+        let error = match prepare_chocolatey_script_in_with_default_install_root(
+            &command,
+            Some(temp_dir.path()),
+            Some(&env),
+            &install_root,
+        ) {
             Ok(_) => panic!("missing trusted choco should fail"),
             Err(error) => error,
         };
@@ -1043,17 +1084,23 @@ mod tests {
         let choco_bin = install_root.path().join("bin");
         std::fs::create_dir_all(&choco_bin).expect("create fake Chocolatey bin");
         std::fs::write(choco_bin.join("choco.exe"), "").expect("create fake choco");
-        let program_data = tempfile::tempdir().expect("create fake ProgramData");
+        let trusted_program_data = tempfile::tempdir().expect("create fake trusted ProgramData");
+        let trusted_install_root = trusted_program_data.path().join("chocolatey");
         let env = HashMap::from([
             (
                 "ChocolateyInstall".to_owned(),
                 install_root.path().display().to_string(),
             ),
-            ("ProgramData".to_owned(), program_data.path().display().to_string()),
+            ("ProgramData".to_owned(), install_root.path().display().to_string()),
         ]);
         let command = vec!["choco.exe".to_owned(), "install".to_owned(), "git".to_owned()];
 
-        let error = match prepare_chocolatey_script_in(&command, Some(temp_dir.path()), Some(&env)) {
+        let error = match prepare_chocolatey_script_in_with_default_install_root(
+            &command,
+            Some(temp_dir.path()),
+            Some(&env),
+            &trusted_install_root,
+        ) {
             Ok(_) => panic!("non-system ChocolateyInstall should fail"),
             Err(error) => error,
         };