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..c57167482 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" @@ -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/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(); 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..09a0c933c --- /dev/null +++ b/devolutions-agent/src/broker/command_builder/chocolatey.rs @@ -0,0 +1,287 @@ +//! 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()); + } + + 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.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"); + } + + 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.name.trim(); + if source.is_empty() { + bail!("Chocolatey package source name 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_and_skip_hash() { + let mut request = make_request(); + request.operation = Operation::Update; + 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(&"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())); + } + + #[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 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(); + 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..e82423fc1 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,70 @@ 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 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");
+    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, 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())?;
+    for arg in args {
+        script.push(' ');
+        append_batch_argument(&mut script, arg)?;
+    }
+    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(|| {
+        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 +609,72 @@ fn trusted_powershell7_executable() -> String {
         .to_string()
 }
 
-fn resolve_winget_executable(env: &std::collections::HashMap) -> anyhow::Result {
+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() -> 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_install_root.to_owned(), 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, default_install_root) {
+        bail!(
+            "ChocolateyInstall points to {}; only the trusted system Chocolatey folder at {} is supported by the broker",
+            install_root.display(),
+            default_install_root.display()
+        );
+    }
+
+    Ok((executable, install_root))
+}
+
+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 {
+    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 +689,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 +708,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 +749,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 +790,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_with_default_install_root,
+        prepare_main_command_in, prepare_shell_command_in,
+    };
 
     #[test]
     fn shell_command_uses_utf8_temp_batch_file() {
@@ -850,4 +1003,107 @@ mod tests {
 
         sddl_string
     }
+
+    #[test]
+    fn chocolatey_command_uses_chocolateyinstall_path_and_batch_wrapper() {
+        let temp_dir = tempfile::tempdir().expect("create temp dir");
+        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(),
+                untrusted_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_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");
+        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("if \"%CHOCO_EXIT_CODE%\"==\"3010\" exit /b 0"));
+        assert!(script.contains("exit /b %CHOCO_EXIT_CODE%"));
+    }
+
+    #[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_with_default_install_root(
+            &command,
+            Some(temp_dir.path()),
+            Some(&env),
+            &install_root,
+        ) {
+            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 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(), 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_with_default_install_root(
+            &command,
+            Some(temp_dir.path()),
+            Some(&env),
+            &trusted_install_root,
+        ) {
+            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],