From 80d02a71d64348fcd9553142b055a4262fc1cb4c Mon Sep 17 00:00:00 2001 From: Vladyslav Nikonov Date: Tue, 14 Jul 2026 17:37:59 +0300 Subject: [PATCH 1/4] refactor(agent-shared): share temporary script helpers Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/devolutions-agent-shared/Cargo.toml | 2 + crates/devolutions-agent-shared/src/lib.rs | 1 + .../devolutions-agent-shared/src/temp_file.rs | 66 +++++++++++++++++++ devolutions-session/Cargo.toml | 2 - devolutions-session/src/dvc/fs.rs | 66 +------------------ devolutions-session/src/dvc/process.rs | 6 ++ devolutions-session/src/dvc/task.rs | 18 ++--- devolutions-session/src/main.rs | 4 +- 8 files changed, 84 insertions(+), 81 deletions(-) create mode 100644 crates/devolutions-agent-shared/src/temp_file.rs diff --git a/crates/devolutions-agent-shared/Cargo.toml b/crates/devolutions-agent-shared/Cargo.toml index f51135bf1..2b2714b81 100644 --- a/crates/devolutions-agent-shared/Cargo.toml +++ b/crates/devolutions-agent-shared/Cargo.toml @@ -14,7 +14,9 @@ camino = "1.1" cfg-if = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" +tempfile = "3" thiserror = "2" +tracing = "0.1" [target.'cfg(windows)'.dependencies] windows-registry = "0.5" diff --git a/crates/devolutions-agent-shared/src/lib.rs b/crates/devolutions-agent-shared/src/lib.rs index dabbc4619..85edfa6be 100644 --- a/crates/devolutions-agent-shared/src/lib.rs +++ b/crates/devolutions-agent-shared/src/lib.rs @@ -2,6 +2,7 @@ pub mod windows; mod date_version; +pub mod temp_file; mod update_manifest; mod update_status; diff --git a/crates/devolutions-agent-shared/src/temp_file.rs b/crates/devolutions-agent-shared/src/temp_file.rs new file mode 100644 index 000000000..5cbb0626d --- /dev/null +++ b/crates/devolutions-agent-shared/src/temp_file.rs @@ -0,0 +1,66 @@ +use std::fs; +use std::io::{self, Write as _}; +use std::path::{Path, PathBuf}; + +use tracing::error; + +pub const BATCH_UTF8_PREAMBLE: &str = "@chcp 65001 > nul"; +pub const POWERSHELL_UTF8_ENCODING_PREAMBLE: &str = + "$OutputEncoding = [Console]::InputEncoding = [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()"; + +/// Guard for a temporary file that is removed on drop. +pub struct TmpFileGuard { + path: PathBuf, +} + +impl TmpFileGuard { + pub fn new(extension: &str) -> io::Result { + Self::with_prefix_in("devolutions-", extension, None) + } + + pub fn with_prefix_in(prefix: &str, extension: &str, temp_dir: Option<&Path>) -> io::Result { + let suffix = format!(".{extension}"); + let mut builder = tempfile::Builder::new(); + builder.prefix(prefix).suffix(&suffix); + + let tempfile = match temp_dir { + Some(temp_dir) => builder.tempfile_in(temp_dir), + None => builder.tempfile(), + }?; + + let (_file, path) = tempfile.keep().map_err(|error| error.error)?; + + Ok(Self { path }) + } + + pub fn write_content(&self, content: &str) -> io::Result<()> { + self.write_bytes(content.as_bytes()) + } + + pub fn write_bytes(&self, content: &[u8]) -> io::Result<()> { + fs::write(&self.path, content) + } + + /// Write content as UTF-8 with a BOM prefix for Windows PowerShell 5.x script files. + pub fn write_content_utf8_bom(&self, content: &str) -> io::Result<()> { + let mut file = fs::File::create(&self.path)?; + file.write_all(b"\xEF\xBB\xBF")?; + file.write_all(content.as_bytes()) + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn path_string(&self) -> String { + self.path.display().to_string() + } +} + +impl Drop for TmpFileGuard { + fn drop(&mut self) { + if let Err(error) = fs::remove_file(&self.path) { + error!(%error, path = %self.path.display(), "Failed to remove temporary file"); + } + } +} diff --git a/devolutions-session/Cargo.toml b/devolutions-session/Cargo.toml index 9699b6d8d..74207b053 100644 --- a/devolutions-session/Cargo.toml +++ b/devolutions-session/Cargo.toml @@ -17,7 +17,6 @@ dvc = [ "dep:async-trait", "dep:windows", "dep:now-proto-pdu", - "dep:tempfile", "dep:thiserror", "dep:win-api-wrappers", ] @@ -38,7 +37,6 @@ futures = "0.3" tokio = { version = "1", features = ["macros", "sync", "time"] } async-trait = { version = "0.1", optional = true } -tempfile = { version = "3", optional = true } thiserror = { version = "2", optional = true } win-api-wrappers = { path = "../crates/win-api-wrappers", optional = true } diff --git a/devolutions-session/src/dvc/fs.rs b/devolutions-session/src/dvc/fs.rs index 2c609c6e3..df8c318a4 100644 --- a/devolutions-session/src/dvc/fs.rs +++ b/devolutions-session/src/dvc/fs.rs @@ -1,65 +1 @@ -use std::path::PathBuf; - -use tracing::error; - -use crate::dvc::encoding::DataEncoding; - -/// Guard for created temporary file. Associated file is deleted on drop. -pub struct TmpFileGuard(PathBuf); - -impl TmpFileGuard { - pub fn new(extension: &str) -> anyhow::Result { - // Create empty temporary file and release the handle. - let (_file, path) = tempfile::Builder::new() - .prefix("devolutions-") - .suffix(&format!(".{extension}")) - .tempfile()? - .keep()?; - - Ok(Self(path)) - } - - pub fn write_content(&self, content: &str) -> anyhow::Result<()> { - std::fs::write(&self.0, content)?; - Ok(()) - } - - /// Write content transcoded from UTF-8 to the specified encoding. - pub fn write_content_encoded(&self, content: &str, encoding: DataEncoding) -> anyhow::Result<()> { - let bytes = encoding.encode_str(content); - std::fs::write(&self.0, &*bytes)?; - Ok(()) - } - - /// Write content as UTF-8 with a BOM prefix (for Windows PowerShell 5.x script files). - pub fn write_content_utf8_bom(&self, content: &str) -> anyhow::Result<()> { - use std::io::Write as _; - - let mut file = std::fs::File::create(&self.0)?; - file.write_all(b"\xEF\xBB\xBF")?; - file.write_all(content.as_bytes())?; - Ok(()) - } - - pub fn path(&self) -> &PathBuf { - &self.0 - } - - pub fn path_string(&self) -> String { - format!("{}", self.0.display()) - } -} - -impl Drop for TmpFileGuard { - fn drop(&mut self) { - // We can't use `MoveFileExW` for scheduled deletion by OS via MOVEFILE_DELAY_UNTIL_REBOOT - // because it requires administrative rights to work, however devolutions-session - // is running under non-elevated user account. - // (see [MSDN](https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-movefileexa#remarks)). - - if let Err(error) = std::fs::remove_file(&self.0) { - let path = format!("{}", self.0.display()); - error!(%error, path, "Failed to remove temporary file"); - } - } -} +pub use devolutions_agent_shared::temp_file::TmpFileGuard; diff --git a/devolutions-session/src/dvc/process.rs b/devolutions-session/src/dvc/process.rs index c7c56f3fa..95efb007e 100644 --- a/devolutions-session/src/dvc/process.rs +++ b/devolutions-session/src/dvc/process.rs @@ -46,6 +46,12 @@ impl From for ExecError { } } +impl From for ExecError { + fn from(error: std::io::Error) -> Self { + ExecError::Other(error.into()) + } +} + impl From> for ExecError { fn from(error: tokio::sync::mpsc::error::SendError) -> Self { ExecError::Other(error.into()) diff --git a/devolutions-session/src/dvc/task.rs b/devolutions-session/src/dvc/task.rs index 21e47cba8..e98d6e98b 100644 --- a/devolutions-session/src/dvc/task.rs +++ b/devolutions-session/src/dvc/task.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use anyhow::{Context, bail}; use async_trait::async_trait; +use devolutions_agent_shared::temp_file::{BATCH_UTF8_PREAMBLE, POWERSHELL_UTF8_ENCODING_PREAMBLE, TmpFileGuard}; use devolutions_gateway_task::Task; use now_proto_pdu::ironrdp_core::IntoOwned; use now_proto_pdu::{ @@ -37,7 +38,6 @@ use windows::core::PCWSTR; use crate::dvc::channel::{WinapiSignaledSender, bounded_mpsc_channel, winapi_signaled_mpsc_channel}; use crate::dvc::encoding::DataEncoding; -use crate::dvc::fs::TmpFileGuard; use crate::dvc::io::run_dvc_io; use crate::dvc::process::{ExecError, ServerChannelEvent, WinApiProcess, WinApiProcessBuilder}; use crate::dvc::rdm::RdmMessageProcessor; @@ -663,7 +663,7 @@ impl MessageProcessor { // - RAW_ENCODING: pass through raw bytes without transcoding. // - UNICODE_CONSOLE: inject `@chcp 65001`, write file as BOM-less UTF-8, IO passthrough. let (file_encoding, io_encoding) = if batch_msg.is_unicode_console() { - script = format!("@chcp 65001 > nul\r\n{}", script); + script = format!("{BATCH_UTF8_PREAMBLE}\r\n{script}"); (DataEncoding::Raw, DataEncoding::Raw) } else if batch_msg.is_raw_encoding() { (DataEncoding::from_oem_codepage(), DataEncoding::Raw) @@ -672,7 +672,7 @@ impl MessageProcessor { }; let tmp_file = TmpFileGuard::new("bat")?; - tmp_file.write_content_encoded(&script, file_encoding)?; + tmp_file.write_bytes(&file_encoding.encode_str(&script))?; let parameters = format!("/Q /C \"{}\"", tmp_file.path_string()); @@ -723,10 +723,7 @@ impl MessageProcessor { let mut script = winps_msg.command().to_owned(); if winps_msg.is_unicode_console() { // Inject UTF-8 encoding setup for Windows PowerShell. - script = format!( - "$OutputEncoding = [Console]::InputEncoding = [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()\r\n{}", - script - ); + script = format!("{POWERSHELL_UTF8_ENCODING_PREAMBLE}\r\n{script}"); } let tmp_file = TmpFileGuard::new("ps1")?; tmp_file.write_content_utf8_bom(&script)?; @@ -786,13 +783,10 @@ impl MessageProcessor { let mut script = pwsh_msg.command().to_owned(); if pwsh_msg.is_unicode_console() { // Inject UTF-8 encoding setup for PowerShell 7+. - script = format!( - "$OutputEncoding = [Console]::InputEncoding = [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()\r\n{}", - script - ); + script = format!("{POWERSHELL_UTF8_ENCODING_PREAMBLE}\r\n{script}"); } let tmp_file = TmpFileGuard::new("ps1")?; - tmp_file.write_content_encoded(&script, DataEncoding::Raw)?; + tmp_file.write_content(&script)?; params.push("-Command".to_owned()); params.push(format!("\"{}\"", tmp_file.path_string())); Some(tmp_file) diff --git a/devolutions-session/src/main.rs b/devolutions-session/src/main.rs index a7b0056ba..97fea4c8c 100644 --- a/devolutions-session/src/main.rs +++ b/devolutions-session/src/main.rs @@ -4,8 +4,8 @@ #[cfg(all(windows, feature = "dvc"))] use ::{ - async_trait as _, devolutions_agent_shared as _, now_proto_pdu as _, tempfile as _, thiserror as _, - win_api_wrappers as _, windows as _, + async_trait as _, devolutions_agent_shared as _, now_proto_pdu as _, thiserror as _, win_api_wrappers as _, + windows as _, }; use ::{ camino as _, cfg_if as _, ctrlc as _, devolutions_log as _, futures as _, parking_lot as _, serde as _, From 327354a3668de8169a70b52408bc76f89c3bcbfc Mon Sep 17 00:00:00 2001 From: Vladyslav Nikonov Date: Tue, 14 Jul 2026 17:38:06 +0300 Subject: [PATCH 2/4] feat(agent): add package broker policy engine Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .gitattributes | 3 + .../samples/corporate-allowlist.policy.json | 134 ++++++ .../samples/deny-risky-options.policy.json | 81 ++++ .../winget-unknown-install.request.json | 31 ++ .../winget-vscode-install.request.json | 32 ++ .../winget-vscode-skiphash.request.json | 31 ++ .../samples/scenarios/baseline.scenarios.json | 38 ++ .../src/broker/command_builder/mod.rs | 38 ++ .../src/broker/command_builder/powershell.rs | 293 +++++++++++++ .../src/broker/command_builder/winget.rs | 209 +++++++++ .../src/broker/evaluator/constraints.rs | 229 ++++++++++ .../src/broker/evaluator/matching.rs | 215 +++++++++ devolutions-agent/src/broker/evaluator/mod.rs | 114 +++++ .../src/broker/evaluator/tests.rs | 214 +++++++++ .../src/broker/evaluator/version.rs | 107 +++++ .../src/broker/evaluator/wildcard.rs | 50 +++ .../src/broker/executor/output.rs | 413 ++++++++++++++++++ .../src/broker/scenario_tests.rs | 131 ++++++ 18 files changed, 2363 insertions(+) create mode 100644 devolutions-agent/src/broker/assets/samples/corporate-allowlist.policy.json create mode 100644 devolutions-agent/src/broker/assets/samples/deny-risky-options.policy.json create mode 100644 devolutions-agent/src/broker/assets/samples/requests/winget-unknown-install.request.json create mode 100644 devolutions-agent/src/broker/assets/samples/requests/winget-vscode-install.request.json create mode 100644 devolutions-agent/src/broker/assets/samples/requests/winget-vscode-skiphash.request.json create mode 100644 devolutions-agent/src/broker/assets/samples/scenarios/baseline.scenarios.json create mode 100644 devolutions-agent/src/broker/command_builder/mod.rs create mode 100644 devolutions-agent/src/broker/command_builder/powershell.rs create mode 100644 devolutions-agent/src/broker/command_builder/winget.rs create mode 100644 devolutions-agent/src/broker/evaluator/constraints.rs create mode 100644 devolutions-agent/src/broker/evaluator/matching.rs create mode 100644 devolutions-agent/src/broker/evaluator/mod.rs create mode 100644 devolutions-agent/src/broker/evaluator/tests.rs create mode 100644 devolutions-agent/src/broker/evaluator/version.rs create mode 100644 devolutions-agent/src/broker/evaluator/wildcard.rs create mode 100644 devolutions-agent/src/broker/executor/output.rs create mode 100644 devolutions-agent/src/broker/scenario_tests.rs diff --git a/.gitattributes b/.gitattributes index 1f8b44bb4..8df0e1b4b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -22,3 +22,6 @@ devolutions-gateway/openapi/dotnet-client/docs/** linguist-generated merge=binar devolutions-gateway/openapi/dotnet-subscriber/src/** linguist-generated merge=binary devolutions-gateway/openapi/ts-angular-client/api/** linguist-generated merge=binary devolutions-gateway/openapi/ts-angular-client/model/** linguist-generated merge=binary + +# Sample assets produce huge LoC counts; exclude them from language statistics. +devolutions-agent/src/broker/assets/** linguist-generated diff --git a/devolutions-agent/src/broker/assets/samples/corporate-allowlist.policy.json b/devolutions-agent/src/broker/assets/samples/corporate-allowlist.policy.json new file mode 100644 index 000000000..4d50bc494 --- /dev/null +++ b/devolutions-agent/src/broker/assets/samples/corporate-allowlist.policy.json @@ -0,0 +1,134 @@ +{ + "$schema": "https://devolutions.net/schemas/now-policy.schema.1.0.json", + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "contoso.desktop.standard-allowlist", + "Publisher": "Contoso IT", + "Revision": 4, + "PublishedAt": "2026-05-05T00:00:00Z", + "Description": "Fail-closed policy for standard workstation package installs." + }, + "Enforcement": { + "DefaultDecision": "Deny", + "RulePrecedence": "PriorityThenDeny" + }, + "Rules": [ + { + "Id": "deny.integrity-bypass", + "Enabled": true, + "Priority": 10, + "Decision": "Deny", + "Reason": "Integrity and publisher checks cannot be bypassed by brokered requests.", + "Match": { + "Operations": [ + "Install", + "Update" + ], + "SkipHashCheck": [ + true + ] + } + }, + { + "Id": "deny.custom-parameters", + "Enabled": true, + "Priority": 20, + "Decision": "Deny", + "Reason": "Custom package-manager parameters are not allowed in the workstation allow list.", + "Match": { + "HasCustomParameters": [ + true + ] + } + }, + { + "Id": "deny.prepost-commands", + "Enabled": true, + "Priority": 30, + "Decision": "Deny", + "Reason": "Pre and post operation commands are not allowed in the workstation allow list.", + "Match": { + "HasPrePostCommands": [ + true + ] + } + }, + { + "Id": "allow.winget.vscode", + "Enabled": true, + "Priority": 100, + "Decision": "Allow", + "Reason": "Visual Studio Code is approved for managed workstations.", + "Match": { + "Operations": [ + "Install", + "Update" + ], + "Managers": [ + "Winget" + ], + "Sources": [ + "winget" + ], + "PackageIdentifiers": [ + "Microsoft.VisualStudioCode" + ], + "Scopes": [ + "User", + "Machine" + ], + "Architectures": [ + "X64", + "Arm64" + ] + }, + "Constraints": { + "AllowInteractive": false, + "AllowSkipHashCheck": false, + "AllowPreRelease": false, + "AllowCustomParameters": false, + "AllowPrePostCommands": false, + "AllowKillBeforeOperation": false + } + }, + { + "Id": "allow.winget.powertoys", + "Enabled": true, + "Priority": 100, + "Decision": "Allow", + "Reason": "PowerToys is approved for developer workstations.", + "Match": { + "Operations": [ + "Install", + "Update" + ], + "Managers": [ + "Winget" + ], + "Sources": [ + "winget" + ], + "PackageIdentifiers": [ + "Microsoft.PowerToys" + ], + "Scopes": [ + "User", + "Machine" + ], + "Architectures": [ + "X64", + "Arm64" + ] + }, + "Constraints": { + "AllowInteractive": false, + "AllowSkipHashCheck": false, + "AllowPreRelease": false, + "AllowCustomParameters": false, + "AllowPrePostCommands": false, + "AllowKillBeforeOperation": false + } + } + ] +} \ No newline at end of file diff --git a/devolutions-agent/src/broker/assets/samples/deny-risky-options.policy.json b/devolutions-agent/src/broker/assets/samples/deny-risky-options.policy.json new file mode 100644 index 000000000..5ddf8d1a9 --- /dev/null +++ b/devolutions-agent/src/broker/assets/samples/deny-risky-options.policy.json @@ -0,0 +1,81 @@ +{ + "$schema": "https://devolutions.net/schemas/now-policy.schema.1.0.json", + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "contoso.desktop.deny-risky-options", + "Publisher": "Contoso IT", + "Revision": 2, + "PublishedAt": "2026-05-05T00:00:00Z", + "Description": "Default-allow policy that blocks risky broker request options." + }, + "Enforcement": { + "DefaultDecision": "Allow", + "RulePrecedence": "PriorityThenDeny" + }, + "Rules": [ + { + "Id": "deny.integrity-bypass", + "Priority": 10, + "Decision": "Deny", + "Reason": "Do not broker installs that skip WinGet hash checks or PowerShell publisher checks.", + "Match": { + "Operations": [ + "Install", + "Update" + ], + "SkipHashCheck": [ + true + ] + } + }, + { + "Id": "deny.manager-custom-parameters", + "Priority": 20, + "Decision": "Deny", + "Reason": "Custom package-manager parameters require a dedicated exception policy.", + "Match": { + "HasCustomParameters": [ + true + ] + } + }, + { + "Id": "deny.prepost-commands", + "Priority": 30, + "Decision": "Deny", + "Reason": "Pre and post operation commands are outside the package manager trust boundary.", + "Match": { + "HasPrePostCommands": [ + true + ] + } + }, + { + "Id": "deny.kill-process-actions", + "Priority": 40, + "Decision": "Deny", + "Reason": "Killing processes before a brokered package operation is not allowed by this policy.", + "Match": { + "HasKillBeforeOperation": [ + true + ] + } + }, + { + "Id": "deny.unapproved-winget-source", + "Priority": 50, + "Decision": "Deny", + "Reason": "Only the default WinGet source is accepted by this deny-list sample.", + "Match": { + "Managers": [ + "Winget" + ], + "Sources": [ + "msstore", + "winget-fonts" + ] + } + } + ] +} \ No newline at end of file diff --git a/devolutions-agent/src/broker/assets/samples/requests/winget-unknown-install.request.json b/devolutions-agent/src/broker/assets/samples/requests/winget-unknown-install.request.json new file mode 100644 index 000000000..264f2f8c9 --- /dev/null +++ b/devolutions-agent/src/broker/assets/samples/requests/winget-unknown-install.request.json @@ -0,0 +1,31 @@ +{ + "RequestKind": "PackageRequest", + "RequestVersion": "1.0", + "RequestId": "req-winget-unknown-install", + "CreatedAt": "2026-05-05T12:05:00Z", + "Operation": "Install", + "Manager": "Winget", + "Source": { + "Name": "winget", + "Url": "https://cdn.winget.microsoft.com/cache" + }, + "Package": { + "Id": "Example.UnapprovedTool", + "Architecture": "X64" + }, + "Options": { + "Scope": "User", + "Interactive": false, + "SkipHashCheck": false, + "PreRelease": false, + "CustomParameters": [], + "KillBeforeOperation": [] + }, + "Client": { + "RequestedElevation": "Standard", + "EffectiveUser": "CONTOSO\\alice", + "ClientVersion": "3.2.0", + "Transport": "HttpNamedPipe", + "ClientExecutablePath": "C:\\Program Files\\Devolutions\\NOW\\now-client.exe" + } +} diff --git a/devolutions-agent/src/broker/assets/samples/requests/winget-vscode-install.request.json b/devolutions-agent/src/broker/assets/samples/requests/winget-vscode-install.request.json new file mode 100644 index 000000000..d771627f0 --- /dev/null +++ b/devolutions-agent/src/broker/assets/samples/requests/winget-vscode-install.request.json @@ -0,0 +1,32 @@ +{ + "RequestKind": "PackageRequest", + "RequestVersion": "1.0", + "RequestId": "req-winget-vscode-install", + "CreatedAt": "2026-05-05T12:00:00Z", + "Operation": "Install", + "Manager": "Winget", + "Source": { + "Name": "winget", + "Url": "https://cdn.winget.microsoft.com/cache" + }, + "Package": { + "Id": "Microsoft.VisualStudioCode", + "Architecture": "X64" + }, + "Options": { + "Scope": "Machine", + "Interactive": false, + "SkipHashCheck": false, + "PreRelease": false, + "CustomParameters": [], + "KillBeforeOperation": [] + }, + "Client": { + "RequestedElevation": "Elevated", + "EffectiveUser": "CONTOSO\\alice", + "ClientVersion": "3.2.0", + "Transport": "HttpNamedPipe", + "ClientExecutablePath": "C:\\Program Files\\Devolutions\\NOW\\now-client.exe" + }, + "IncludeCommandPreview": true +} diff --git a/devolutions-agent/src/broker/assets/samples/requests/winget-vscode-skiphash.request.json b/devolutions-agent/src/broker/assets/samples/requests/winget-vscode-skiphash.request.json new file mode 100644 index 000000000..92a566cd5 --- /dev/null +++ b/devolutions-agent/src/broker/assets/samples/requests/winget-vscode-skiphash.request.json @@ -0,0 +1,31 @@ +{ + "RequestKind": "PackageRequest", + "RequestVersion": "1.0", + "RequestId": "req-winget-vscode-skiphash", + "CreatedAt": "2026-05-05T12:10:00Z", + "Operation": "Install", + "Manager": "Winget", + "Source": { + "Name": "winget", + "Url": "https://cdn.winget.microsoft.com/cache" + }, + "Package": { + "Id": "Microsoft.VisualStudioCode", + "Architecture": "X64" + }, + "Options": { + "Scope": "Machine", + "Interactive": false, + "SkipHashCheck": true, + "PreRelease": false, + "CustomParameters": [], + "KillBeforeOperation": [] + }, + "Client": { + "RequestedElevation": "Elevated", + "EffectiveUser": "CONTOSO\\alice", + "ClientVersion": "3.2.0", + "Transport": "HttpNamedPipe", + "ClientExecutablePath": "C:\\Program Files\\Devolutions\\NOW\\now-client.exe" + } +} diff --git a/devolutions-agent/src/broker/assets/samples/scenarios/baseline.scenarios.json b/devolutions-agent/src/broker/assets/samples/scenarios/baseline.scenarios.json new file mode 100644 index 000000000..9e8ee611e --- /dev/null +++ b/devolutions-agent/src/broker/assets/samples/scenarios/baseline.scenarios.json @@ -0,0 +1,38 @@ +{ + "ScenarioSet": "baseline", + "Description": "Broad end-to-end scenarios for the gateway-owned package broker policy flow.", + "Scenarios": [ + { + "Id": "baseline.winget.vscode.allow", + "Policy": "corporate-allowlist.policy.json", + "Request": "requests/winget-vscode-install.request.json", + "ExpectedDecision": "Allow", + "ExpectedRuleId": "allow.winget.vscode", + "Tags": ["baseline", "winget", "allowlist"] + }, + { + "Id": "baseline.winget.unknown.default-deny", + "Policy": "corporate-allowlist.policy.json", + "Request": "requests/winget-unknown-install.request.json", + "ExpectedDecision": "Deny", + "ExpectedRuleId": "", + "Tags": ["baseline", "winget", "default-deny"] + }, + { + "Id": "baseline.winget.skiphash.deny", + "Policy": "corporate-allowlist.policy.json", + "Request": "requests/winget-vscode-skiphash.request.json", + "ExpectedDecision": "Deny", + "ExpectedRuleId": "deny.integrity-bypass", + "Tags": ["baseline", "winget", "risky-options"] + }, + { + "Id": "baseline.default-allow.normal-install", + "Policy": "deny-risky-options.policy.json", + "Request": "requests/winget-vscode-install.request.json", + "ExpectedDecision": "Allow", + "ExpectedRuleId": "", + "Tags": ["baseline", "winget", "default-allow"] + } + ] +} \ No newline at end of file diff --git a/devolutions-agent/src/broker/command_builder/mod.rs b/devolutions-agent/src/broker/command_builder/mod.rs new file mode 100644 index 000000000..f50bd0a00 --- /dev/null +++ b/devolutions-agent/src/broker/command_builder/mod.rs @@ -0,0 +1,38 @@ +//! Command-line builders for package manager operations. +//! +//! Constructs the commands the broker would execute from validated request fields. +//! The broker never executes client-supplied commands directly. + +pub mod powershell; +pub mod winget; + +use now_policy_api::{ManagerName, PackageRequest}; + +/// Build a command line from a validated request, dispatching to the appropriate +/// package manager builder. +/// +/// Returns the command as a list of arguments (first element is the executable). +pub fn build_command(request: &PackageRequest) -> Vec { + match request.manager { + ManagerName::Winget => winget::build_winget_command(request), + ManagerName::PowerShell => powershell::build_powershell5_command(request), + ManagerName::PowerShell7 => powershell::build_powershell7_command(request), + } +} + +/// Append `--flag value` to command if value is `Some` and non-empty. +pub(crate) fn set_if_specified(command: &mut Vec, flag: &str, value: Option<&str>) { + if let Some(v) = value + && !v.is_empty() + { + command.push(flag.to_owned()); + command.push(v.to_owned()); + } +} + +/// Append `--flag` to command if value is true. +pub(crate) fn set_if_true(command: &mut Vec, flag: &str, value: bool) { + if value { + command.push(flag.to_owned()); + } +} diff --git a/devolutions-agent/src/broker/command_builder/powershell.rs b/devolutions-agent/src/broker/command_builder/powershell.rs new file mode 100644 index 000000000..7318cc7ea --- /dev/null +++ b/devolutions-agent/src/broker/command_builder/powershell.rs @@ -0,0 +1,293 @@ +//! PowerShell command-line builders for WinPS 5.x and PowerShell 7.x. +//! +//! These mirror package broker client PowerShell manager helpers so the broker runs the +//! same command the unelevated client would have run: +//! - PowerShell 5 uses PowerShellGet (`Install-Module`/`Update-Module`/`Uninstall-Module`) +//! prepared as `powershell.exe -NoProfile -Command