Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d1fe413
feat(agent,agent-installer): add policy consent helper
CBenoit Sep 9, 2026
089c76b
fix(agent): enforce signer revocation checks
CBenoit Sep 9, 2026
2ace003
fix(agent): bind broker trust to running image
CBenoit Sep 10, 2026
7160af7
fix(agent,agent-installer): correct helper packaging
CBenoit Sep 10, 2026
04371ab
fix(agent): validate policy credentials
CBenoit Sep 10, 2026
a17351d
fix(agent,agent-installer): require current UI signer
CBenoit Sep 17, 2026
85ca006
fix(agent,agent-installer): discover broker pipe
CBenoit Sep 17, 2026
95cce81
docs(agent-installer): add helper package step
CBenoit Sep 17, 2026
2c2354c
fix(agent): require local consent parent
CBenoit Sep 17, 2026
c81c1b9
fix(agent): resolve retained consent paths
CBenoit Sep 17, 2026
db757ad
fix(agent): align policy pipe deadline
CBenoit Sep 17, 2026
6466a19
fix(agent): limit consent pipe timeout
CBenoit Sep 17, 2026
0fadc51
fix(agent): extend authorized policy writes
CBenoit Sep 17, 2026
db8482d
fix(agent): recover interrupted policy probes
CBenoit Sep 17, 2026
e31c8ed
fix(agent): match Unicode policy sources
CBenoit Sep 17, 2026
11b4733
fix(agent): normalize policy source names
CBenoit Sep 17, 2026
cc47854
fix(agent): reject ambiguous policy sources
CBenoit Sep 17, 2026
e8f56e6
fix(agent): validate package source identity
CBenoit Sep 17, 2026
a92258d
fix(agent): bound pipe connections from accept
CBenoit Sep 17, 2026
25155a5
fix(agent): reject padded policy sources
CBenoit Sep 17, 2026
3ac7208
fix(agent): preserve manager source identity
CBenoit Sep 17, 2026
ee9613e
fix(agent): validate policy source spelling
CBenoit Sep 17, 2026
0a279f2
test(agent): cover policy source spelling guard
CBenoit Sep 17, 2026
bed8690
fix(agent,agent-installer): publish helper discovery in both views
CBenoit Sep 18, 2026
c13d2a1
fix(agent): align helper policy contract
CBenoit Sep 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -887,6 +887,9 @@ jobs:
$DAgentSessionExecutable = Join-Path $TargetOutputPath "DevolutionsSession.exe"
echo "dagent-session-executable=$DAgentSessionExecutable" >> $Env:GITHUB_OUTPUT

$DAgentPolicyConsentHelper = Join-Path $TargetOutputPath "DevolutionsAgentPolicyConsent.exe"
echo "dagent-policy-consent-helper=$DAgentPolicyConsentHelper" >> $Env:GITHUB_OUTPUT

$DAgentUpdaterExecutable = Join-Path $TargetOutputPath "DevolutionsAgentUpdater.exe"
echo "dagent-updater-executable=$DAgentUpdaterExecutable" >> $Env:GITHUB_OUTPUT
}
Expand Down Expand Up @@ -1052,6 +1055,28 @@ jobs:
DAGENT_EXECUTABLE: ${{ steps.load-variables.outputs.dagent-executable }}
TARGET_OUTPUT_PATH: ${{ steps.load-variables.outputs.target-output-path }}

- name: Build NativeAOT policy consent helper
if: ${{ matrix.os == 'windows' }}
run: |
$Rid = "win-${{ matrix.arch }}"
$Output = Split-Path -Parent '${{ steps.load-variables.outputs.dagent-policy-consent-helper }}'
dotnet publish package/AgentPolicyConsent/DevolutionsAgentPolicyConsent.csproj `
--configuration Release `
--runtime $Rid `
--output $Output `
-p:Version=${{ needs.preflight.outputs.version }}
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
$Helper = '${{ steps.load-variables.outputs.dagent-policy-consent-helper }}'
if (-Not (Test-Path -LiteralPath $Helper -PathType Leaf)) {
throw "NativeAOT policy consent helper was not produced"
}
if ((Get-Item -LiteralPath $Helper).Length -gt 8MB) {
throw "NativeAOT policy consent helper exceeds 8 MiB"
}
shell: pwsh

- name: Package
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
run: |
Expand All @@ -1065,6 +1090,7 @@ jobs:
$Env:DAGENT_PEDM_SHELL_EXT_DLL = "${{ steps.load-variables.outputs.dagent-pedm-shell-ext-dll }}"
$Env:DAGENT_PEDM_SHELL_EXT_MSIX = "${{ steps.load-variables.outputs.dagent-pedm-shell-ext-msix }}"
$Env:DAGENT_SESSION_EXECUTABLE = "${{ steps.load-variables.outputs.dagent-session-executable }}"
$Env:DAGENT_POLICY_CONSENT_HELPER = "${{ steps.load-variables.outputs.dagent-policy-consent-helper }}"
$Env:DAGENT_TUN2SOCKS_EXE = "${{ steps.tun2socks.outputs.tun2socks-executable-path }}"
$Env:DAGENT_WINTUN_DLL = "${{ steps.tun2socks.outputs.wintun-library-path }}"
$Env:DAGENT_MULTI_PWSH_EXECUTABLE = "${{ steps.multi-pwsh.outputs.executable-path }}"
Expand Down Expand Up @@ -1186,6 +1212,11 @@ jobs:
run: dotnet test package/AgentWindowsManaged.Tests/DevolutionsAgent.Installer.Tests.csproj
shell: pwsh

- name: Policy consent helper tests
run: dotnet test package/AgentPolicyConsent.Tests/DevolutionsAgentPolicyConsent.Tests.csproj -c Release
Comment thread
CBenoit marked this conversation as resolved.
shell: pwsh


winapi-sanitizer-tests:
name: Windows API sanitizer tests
runs-on: windows-2022
Expand Down
7 changes: 5 additions & 2 deletions .github/workflows/package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ jobs:
run: |
$IncludePattern = @(switch ('${{ matrix.project }}') {
'devolutions-gateway' { @('DevolutionsGateway.exe') }
'devolutions-agent' { @('DevolutionsAgent.exe', 'DevolutionsAgentUpdater.exe', 'DevolutionsPedmShellExt.dll', 'DevolutionsPedmShellExt.msix', 'DevolutionsDesktopAgent.exe') }
'devolutions-agent' { @('DevolutionsAgent.exe', 'DevolutionsAgentUpdater.exe', 'DevolutionsAgentPolicyConsent.exe', 'DevolutionsPedmShellExt.dll', 'DevolutionsPedmShellExt.msix', 'DevolutionsDesktopAgent.exe') }
'jetsocat' { @('jetsocat.exe', 'jetsocat') }
})
$ExcludePattern = "*.pdb"
Expand Down Expand Up @@ -495,6 +495,7 @@ jobs:
$Env:DAGENT_PEDM_SHELL_EXT_DLL = Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsPedmShellExt.dll' -File | Select-Object -First 1
$Env:DAGENT_PEDM_SHELL_EXT_MSIX = Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsPedmShellExt.msix' -File | Select-Object -First 1
$Env:DAGENT_SESSION_EXECUTABLE = Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsSession.exe' -File | Select-Object -First 1
$Env:DAGENT_POLICY_CONSENT_HELPER = Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsAgentPolicyConsent.exe' -File | Select-Object -First 1
$Env:DAGENT_TUN2SOCKS_EXE = Join-Path $ArchRoot 'tun2socks.exe'
$Env:DAGENT_WINTUN_DLL = Join-Path $ArchRoot 'wintun.dll'
$MultiPwshDirectory = Join-Path $Env:RUNNER_TEMP 'multi-pwsh' 'windows' $Arch
Expand All @@ -508,6 +509,7 @@ jobs:
Write-Host "DAGENT_PEDM_SHELL_EXT_DLL = ${Env:DAGENT_PEDM_SHELL_EXT_DLL}"
Write-Host "DAGENT_PEDM_SHELL_EXT_MSIX = ${Env:DAGENT_PEDM_SHELL_EXT_MSIX}"
Write-Host "DAGENT_SESSION_EXECUTABLE = ${Env:DAGENT_SESSION_EXECUTABLE}"
Write-Host "DAGENT_POLICY_CONSENT_HELPER = ${Env:DAGENT_POLICY_CONSENT_HELPER}"
Write-Host "DAGENT_TUN2SOCKS_EXE = ${Env:DAGENT_TUN2SOCKS_EXE}"
Write-Host "DAGENT_WINTUN_DLL = ${Env:DAGENT_WINTUN_DLL}"
Write-Host "DAGENT_MULTI_PWSH_EXECUTABLE = ${Env:DAGENT_MULTI_PWSH_EXECUTABLE}"
Expand All @@ -534,7 +536,8 @@ jobs:
@((Join-Path $ArchRoot DesktopAgent),
(Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsPedmShellExt.dll' | Select-Object -First 1),
(Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsPedmShellExt.msix' | Select-Object -First 1),
(Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsSession.exe' | Select-Object -First 1)) | ForEach-Object {
(Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsSession.exe' | Select-Object -First 1),
(Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsAgentPolicyConsent.exe' | Select-Object -First 1)) | ForEach-Object {
Remove-Item $_ -Recurse -ErrorAction SilentlyContinue | Out-Null
}
}
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 20 additions & 2 deletions ci/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,32 @@ This folder contains PowerShell scripts for CI, building, and packaging.
| Gateway | Windows (regular) | `build.ps1 gateway`<br />`copy-ps-module.ps1`<br />`package-gateway-windows.ps1` |
| Gateway | Windows (assembled) | `build.ps1 gateway`<br />`copy-ps-module.ps1`<br />`package-gateway-windows.ps1 -Generate`<br />`package-assembled.ps1 gateway` |
| Gateway | Linux | `build.ps1 gateway`<br />`package-gateway-linux.ps1` (not available yet) |
| Agent | Windows (regular) | `build.ps1 agent` <br />`build.ps1 pedm`<br />`build.ps1 session`<br />`..\dotnet\DesktopAgent\build.ps1`<br />`package-agent-windows.ps1` |
| Agent | Windows (assembled) | `build.ps1 agent` <br />`build.ps1 pedm`<br />`build.ps1 session`<br />`..\dotnet\DesktopAgent\build.ps1`<br />`package-agent-windows.ps1 -Generate`<br />`package-assembled.ps1 agent` |
| Agent | Windows (regular) | `build.ps1 agent`<br />`build.ps1 pedm`<br />`build.ps1 session`<br />`..\dotnet\DesktopAgent\build.ps1`<br />`dotnet publish ..\package\AgentPolicyConsent\DevolutionsAgentPolicyConsent.csproj -c Release -r win-x64 --self-contained`<br />`package-agent-windows.ps1` with the arguments below |
| Agent | Windows (assembled) | `build.ps1 agent`<br />`build.ps1 pedm`<br />`build.ps1 session`<br />`..\dotnet\DesktopAgent\build.ps1`<br />`dotnet publish ..\package\AgentPolicyConsent\DevolutionsAgentPolicyConsent.csproj -c Release -r win-x64 --self-contained`<br />`package-agent-windows.ps1 -Generate` with the arguments below<br />`package-assembled.ps1 agent` |
| Jetsocat | Windows/macOS/Linux | `build.ps1 jetsocat`<br />Jetsocat is not packaged. |
| Session | Windows/macOS/Linux | `build.ps1 session` <br />Session is not packaged. |
| PEDM module | Windows | `build.ps1 pedm` |
| PowerShell module | Windows | `copy-ps-module.ps1` |
| Desktop Agent | Windows | `..\dotnet\DesktopAgent\build.ps1` |

## Agent Windows package arguments

Pass every staged artifact to `package-agent-windows.ps1`.

```powershell
.\package-agent-windows.ps1 `
-Exe <DevolutionsAgent.exe> `
-UpdaterExe <DevolutionsAgentUpdater.exe> `
-PedmDll <DevolutionsPedmShellExt.dll> `
-PedmMsix <DevolutionsPedmShellExt.msix> `
-SessionExe <DevolutionsSession.exe> `
-PolicyConsentHelper ..\package\AgentPolicyConsent\bin\Release\net10.0-windows\win-x64\publish\DevolutionsAgentPolicyConsent.exe `
-Architecture x64 `
-Outfile <DevolutionsAgent.msi>
```

For an assembled package, replace `-Outfile <DevolutionsAgent.msi>` with `-Generate`.

## What is the difference between _Windows (regular)_ and _Windows (assembled)_?

_Windows (regular)_ is the "normal" build process where the MSI is built by WiX but not signed. This is used in _ci.yaml_. _Windows (assembled)_ is a two-step process where the `-Generate` flag is used to build supporting files for the MSI, including DLLs, language transforms, and _cmd_ scripts. The MSI is assembled in second step using _package-assembled.ps1_. The two-step approach is described [here](https://github.com/oleg-shilo/wixsharp/wiki/Developer's-Guide#compiling-wix-project).
Expand Down
15 changes: 12 additions & 3 deletions ci/package-agent-windows.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ param(
[parameter(Mandatory = $true)]
[string] $SessionExe,
[parameter(Mandatory = $true)]
[string] $PolicyConsentHelper,
[parameter(Mandatory = $true)]
[ValidateSet('x64', 'arm64')]
[string] $Architecture,
[string] $Outfile
Expand Down Expand Up @@ -43,8 +45,9 @@ function Set-FileNameAndCopy {

# If the name is already correct, return the original path without copying
if ($currName -ieq $NewName) {
Write-Host "Using $Path without copying"
return $Path
$resolvedPath = (Resolve-Path -LiteralPath $Path).Path
Write-Host "Using $resolvedPath without copying"
return $resolvedPath
}

# Copy to a temporary directory.
Expand Down Expand Up @@ -98,6 +101,9 @@ function New-AgentMsi() {
# The path to the devolutions-session.exe file.
[string] $SessionExe,
[parameter(Mandatory = $true)]
# The path to the signed DevolutionsAgentPolicyConsent.exe file.
[string] $PolicyConsentHelper,
[parameter(Mandatory = $true)]
[ValidateSet('x64', 'arm64')]
# Architecture: x64 or arm64
[string] $Architecture,
Expand All @@ -120,6 +126,7 @@ function New-AgentMsi() {
$PedmDll = Convert-Path -Path $PedmDll
$PedmMsix = Convert-Path -Path $PedmMsix
$SessionExe = Convert-Path -Path $SessionExe
$PolicyConsentHelper = Convert-Path -Path $PolicyConsentHelper
if ($Outfile) {
$Outfile = Convert-Path -Path $Outfile
}
Expand All @@ -137,6 +144,7 @@ function New-AgentMsi() {
$myUpdaterExe = Set-FileNameAndCopy -Path $UpdaterExe -NewName 'DevolutionsAgentUpdater.exe'
# The session is a service that gets launched on demand.
$mySessionExe = Set-FileNameAndCopy -Path $SessionExe -NewName 'DevolutionsSession.exe'
$myPolicyConsentHelper = Set-FileNameAndCopy -Path $PolicyConsentHelper -NewName 'DevolutionsAgentPolicyConsent.exe'
Comment thread
CBenoit marked this conversation as resolved.

Write-Output "$repoDir\dotnet\DesktopAgent\bin\Release\net48\DevolutionsDesktopAgent.exe"

Expand All @@ -145,6 +153,7 @@ function New-AgentMsi() {
Set-EnvVarPath 'DAGENT_PEDM_SHELL_EXT_DLL' $myPedmDll
Set-EnvVarPath 'DAGENT_PEDM_SHELL_EXT_MSIX' $myPedmMsix
Set-EnvVarPath 'DAGENT_SESSION_EXECUTABLE' $mySessionExe
Set-EnvVarPath 'DAGENT_POLICY_CONSENT_HELPER' $myPolicyConsentHelper

# The actual DevolutionsDesktopAgent.exe will be `\dotnet\DesktopAgent\bin\Release\net48\DevolutionsDesktopAgent.exe`.
# After install, the contents of `net48` will be copied to `C:\Program Files\Devolutions\Agent\desktop\`.
Expand Down Expand Up @@ -184,4 +193,4 @@ function New-AgentMsi() {
Pop-Location
}

New-AgentMsi -Generate:($Generate.IsPresent) -Exe $Exe -UpdaterExe $UpdaterExe -PedmDll $PedmDll -PedmMsix $PedmMsix -SessionExe $SessionExe -Architecture $Architecture -Outfile $Outfile
New-AgentMsi -Generate:($Generate.IsPresent) -Exe $Exe -UpdaterExe $UpdaterExe -PedmDll $PedmDll -PedmMsix $PedmMsix -SessionExe $SessionExe -PolicyConsentHelper $PolicyConsentHelper -Architecture $Architecture -Outfile $Outfile
1 change: 1 addition & 0 deletions crates/now-package-broker/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ tokio = { version = "1.52", features = ["net", "io-util", "rt", "macros", "parki
tokio-util = "0.7"
tower-service = "0.3"
tracing = "0.1"
unicode-normalization = "0.1"
uuid = { version = "1.23", features = ["v4"] }
widestring = "1.2"
win-api-wrappers = { path = "../win-api-wrappers" }
Expand Down
83 changes: 83 additions & 0 deletions crates/now-package-broker/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ use windows::Win32::System::Threading::{
use crate::policy_security::RetainedExecutableSecurity;

const PROCESS_SYNCHRONIZE: PROCESS_ACCESS_RIGHTS = PROCESS_ACCESS_RIGHTS(0x0010_0000);
const POLICY_CONSENT_HELPER_NAME: &str = "DevolutionsAgentPolicyConsent.exe";
const PROCESS_IDENTITY_ACCESS: PROCESS_ACCESS_RIGHTS = PROCESS_ACCESS_RIGHTS(
PROCESS_QUERY_INFORMATION.0 | PROCESS_QUERY_LIMITED_INFORMATION.0 | PROCESS_VM_READ.0 | PROCESS_SYNCHRONIZE.0,
);
Expand Down Expand Up @@ -289,6 +290,20 @@ impl PipeClient {
Ok(())
}

pub(crate) fn validate_policy_write(&self, skip_signature_validation: bool) -> anyhow::Result<()> {
self.validate_connection(skip_signature_validation)?;
// Dev builds cannot enforce helper identity when signature validation is explicitly disabled.
if signature_validation_skipped(skip_signature_validation) {
return Ok(());
}
let agent = std::env::current_exe().context("failed to query Agent executable path")?;
let executable_file = self
.executable_file
.as_deref()
.context("policy consent helper executable handle is not retained")?;
Self::validate_policy_consent_helper_path(&self.executable_path, executable_file, &agent)
}

fn validate_process_instance(&self) -> anyhow::Result<()> {
let Some(process) = &self.process else {
return Ok(());
Expand All @@ -304,6 +319,29 @@ impl PipeClient {
)
}

fn validate_policy_consent_helper_path(client: &Path, client_file: &File, agent: &Path) -> anyhow::Result<()> {
if !client
.file_name()
.is_some_and(|name| name.eq_ignore_ascii_case(POLICY_CONSENT_HELPER_NAME))
{
bail!("policy replacement requires the Agent policy consent helper");
}
let expected = agent
.parent()
.context("Agent executable has no installation directory")?
.join(POLICY_CONSENT_HELPER_NAME);
if !crate::policy_security::windows_paths_equal(client, &expected) {
bail!("policy consent helper is not the installed Agent helper path");
}
let expected_id = file_id(&expected).context("failed to query installed policy consent helper identity")?;
let retained_id =
file_id_from_handle(client_file).context("failed to query retained policy consent helper identity")?;
if !same_file(&expected_id, &retained_id) {
bail!("policy consent helper does not match the installed helper");
}
Ok(())
}

/// Validate that the request's `effective_user` denotes the authenticated pipe client user.
///
/// The name is resolved to a SID and compared against the SID captured at connect,
Expand Down Expand Up @@ -539,6 +577,51 @@ mod tests {
.expect_err("a recycled PID with a different creation time must be rejected");
}

#[test]
fn policy_consent_helper_requires_exact_agent_sibling_path() {
let current_executable = std::env::current_exe().expect("current executable");
let current_file = open_executable_file(&current_executable).expect("open current executable");
let agent = Path::new(r"C:\Program Files\Devolutions\Agent\DevolutionsAgent.exe");
assert!(
PipeClient::validate_policy_consent_helper_path(
Path::new(r"C:\Program Files\Devolutions\Agent\DevolutionsAgentPolicyConsent.exe"),
&current_file,
agent,
)
.is_err(),
"path text alone must not authorize a different retained image"
);
assert!(
PipeClient::validate_policy_consent_helper_path(
Path::new(r"C:\Users\Alice\DevolutionsAgentPolicyConsent.exe"),
&current_file,
agent,
)
.is_err()
);
assert!(
PipeClient::validate_policy_consent_helper_path(
Path::new(r"C:\Users\Alice\UniGetUI.exe"),
&current_file,
agent,
)
.is_err()
);
}

#[test]
fn policy_consent_helper_accepts_exact_retained_sibling() {
let temp = tempfile::tempdir().expect("temp directory");
let agent = temp.path().join("DevolutionsAgent.exe");
let helper = temp.path().join(POLICY_CONSENT_HELPER_NAME);
std::fs::write(&agent, b"agent path anchor").expect("write Agent path anchor");
std::fs::copy(std::env::current_exe().expect("current executable"), &helper).expect("copy helper fixture");
let retained = open_executable_file(&helper).expect("retain helper fixture");

PipeClient::validate_policy_consent_helper_path(&helper, &retained, &agent)
.expect("exact retained sibling must be accepted");
}

#[test]
fn exited_process_cannot_supply_executable_identity() {
let mut child = std::process::Command::new("powershell.exe")
Expand Down
Loading
Loading