Skip to content

feat: add elevated execution support to exec messages - #102

Closed
Richard Markiewicz (thenextman) wants to merge 2 commits into
masterfrom
feat/exec-elevation
Closed

feat: add elevated execution support to exec messages#102
Richard Markiewicz (thenextman) wants to merge 2 commits into
masterfrom
feat/exec-elevation

Conversation

@thenextman

Copy link
Copy Markdown
Member

Summary

Updates NOW-PROTO to v1.7, adding the ability for a client to request that an exec session run with elevated privileges.

RDM has had a "Run as administrator" checkbox in Execute script via Devolutions Agent since the legacy RDM Jump days, where it worked (the receiving RDM ran ShellExecute with the runas verb). The jump transport was removed in
RDMW-21914, and the NOW exec path has no way to carry the intent so the checkbox has silently done nothing since.

This PR adds the protocol half.

What this adds

  • NOW_EXEC_FLAG_*_ELEVATED on the run, process, shell, batch, winps and pwsh exec messages.
  • NOW_CAP_EXEC_ELEVATE_SHELL (0x0080), advertised by servers that elevate through the platform shell: may prompt the interactive user for consent, and cannot redirect stdio.
  • NOW_CAP_EXEC_ELEVATE_TOKEN (0x0100), reserved and unimplemented - elevation by assigning an elevated primary token, which preserves IO redirection and raises no prompt. Deliberately excluded from NowCapabilityExec.All so no peer can advertise it yet.
  • NOW_EXEC_FLAG_BATCH_NO_EXIT (0x0010) — cmd /K rather than /C. Under shell elevation there is no stdio, so a console left open is the only way the user sees output; winps/pwsh already have NO_EXIT, batch did not. MUST be ignored when the session redirects stdio, where the hidden interpreter would never exit.
  • A new "Elevated Execution" spec section defining the semantics.

Design: intent, not mechanism

The main thing to review. Two rules, both aimed at letting the token mechanism land later without a breaking change:

  1. The message expresses intent only. The flag is ELEVATED, never RUNAS. A client cannot select a mechanism, so a server is free to change how it elevates without any client change.
  2. The mechanism is advertised by the server in execCapset, the same way UNICODE_CONSOLE and IO_REDIRECTION already are. A client reads the capability and knows before sending what elevation will cost it.

The spec section pins down what each mechanism means for stdio, result reporting and abort, so tightening any of it later is a documented capability change rather than a silent behaviour change. It also requires that a server fail rather than execute unelevated after being asked to elevate. Silently downgrading is the exact defect this work exists to fix.

Bit allocation

Each exec message has its own flag namespace; values were picked to avoid every bit already in use.

Message Flag Value
NOW_EXEC_RUN_MSG NOW_EXEC_FLAG_RUN_ELEVATED 0x0002
NOW_EXEC_PROCESS_MSG NOW_EXEC_FLAG_PROCESS_ELEVATED 0x0008
NOW_EXEC_SHELL_MSG NOW_EXEC_FLAG_SHELL_ELEVATED 0x0008
NOW_EXEC_BATCH_MSG NOW_EXEC_FLAG_BATCH_ELEVATED 0x0008
NOW_EXEC_BATCH_MSG NOW_EXEC_FLAG_BATCH_NO_EXIT 0x0010
NOW_EXEC_WINPS_MSG / NOW_EXEC_PWSH_MSG NOW_EXEC_FLAG_PS_ELEVATED 0x0800

Included bug fix

NowExecRunMsg::decode_from_body discarded the header flags and rebuilt them from payload content, making it the only exec message that did not preserve flags on decode. Any flag added to NOW_EXEC_RUN_MSG - including ELEVATED - was silently dropped by the decoder, so the agent would never have seen the request. Now seeded from header.flags; DIRECTORY_SET is still derived from the payload because peers older than v1.1 omit the directory field entirely.

Caught by the new round-trip test. Happy to split this into its own fix: commit if you want it to appear as a separate changelog entry — it's a latent bug in released 0.4.4, independent of this feature.

Testing

  • 12 new round-trip tests, 6 per language, asserting ELEVATED (and NO_EXIT for batch) survives encode → decode for every exec style.
  • The .NET byte arrays are the exact encodings the Rust suite asserts, so the two implementations are pinned to the same wire format.
  • Version-byte snapshots updated for 1.7 in both suites.
  • Full gate run: rust fmt, dotnet fmt, check typos, rust tests (60), rust lints, dotnet tests (59), dotnet build, check locks — all green.

Notes for the reviewer

  • NOW-spec.pdf not regenerated. The PDF is not tracked in git.
  • No supports_exec_elevation() version helper, deviating from the 1.6 precedent which added supports_exec_unicode_console(). Elevation is meant to be gated on the capability bit, not the version, and a version helper would invite the wrong pattern.

Follow-up work

This PR is protocol-only; nothing changes behaviour yet.

  1. devolutions-gateway - honour ELEVATED in process_exec_run (verb selection, the smallest increment), advertise ELEVATE_SHELL, and map consent-declined (ERROR_CANCELLED) to a distinct status.
  2. devolutions-gateway - shell-launch path for batch/winps/pwsh, reusing the existing unused win_api_wrappers::process::shell_execute so elevated sessions still report a real exit code.
  3. RDM - send the flag, gate the checkbox on the capability, and state in the UI that elevated runs have no output capture.

Copilot AI balanced review requested due to automatic review settings September 9, 2026 17:35

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The Rust all-capabilities mask can advertise reserved token elevation, and the .NET batch client cannot request the new NO_EXIT behavior.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds NOW-PROTO 1.7 elevated-execution intent, capability negotiation, and batch NO_EXIT support across Rust and .NET.

Changes:

  • Adds elevation flags and capability bits.
  • Preserves Rust Run flags during decoding.
  • Adds cross-language wire-format tests and specification updates.
File summaries
File Description
protocols/rust/now-proto-testsuite/tests/proto/exec.rs Tests elevated exec flags.
protocols/rust/now-proto-testsuite/tests/proto/channel.rs Updates version snapshots.
protocols/rust/now-proto-pdu/src/exec/win_ps.rs Adds WinPS elevation.
protocols/rust/now-proto-pdu/src/exec/shell.rs Adds shell elevation.
protocols/rust/now-proto-pdu/src/exec/run.rs Adds elevation and preserves decoded flags.
protocols/rust/now-proto-pdu/src/exec/pwsh.rs Adds pwsh elevation.
protocols/rust/now-proto-pdu/src/exec/process.rs Adds process elevation.
protocols/rust/now-proto-pdu/src/exec/batch.rs Adds elevation and NO_EXIT.
protocols/rust/now-proto-pdu/src/channel/capset.rs Adds capabilities and version 1.7.
protocols/dotnet/Devolutions.NowProto/src/NowProtoVersion.cs Updates protocol version.
protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecWinPs.cs Adds WinPS elevation.
protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecShell.cs Adds shell elevation.
protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecRun.cs Adds Run elevation.
protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecPwsh.cs Adds pwsh elevation.
protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecProcess.cs Adds process elevation.
protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecBatch.cs Adds elevation and NO_EXIT.
protocols/dotnet/Devolutions.NowProto/src/Capabilities/NowCapabilityExec.cs Defines elevation capabilities.
protocols/dotnet/Devolutions.NowProto.Tests/src/NowExecStyles.cs Tests exec encodings.
protocols/dotnet/Devolutions.NowProto.Tests/src/MsgChannel.cs Updates version snapshots.
protocols/dotnet/Devolutions.NowClient/src/ExecWinPsParams.cs Exposes WinPS elevation.
protocols/dotnet/Devolutions.NowClient/src/ExecShellParams.cs Exposes shell elevation.
protocols/dotnet/Devolutions.NowClient/src/ExecRunParams.cs Exposes Run elevation.
protocols/dotnet/Devolutions.NowClient/src/ExecPwshParams.cs Exposes pwsh elevation.
protocols/dotnet/Devolutions.NowClient/src/ExecProcessParams.cs Exposes process elevation.
protocols/dotnet/Devolutions.NowClient/src/ExecBatchParams.cs Exposes batch elevation.
protocols/docs/NOW-spec.md Documents NOW-PROTO 1.7 semantics.
Review details
  • Files reviewed: 26/26 changed files
  • Comments generated: 3
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread protocols/dotnet/Devolutions.NowClient/src/ExecBatchParams.cs
Comment thread protocols/rust/now-proto-pdu/src/channel/capset.rs Outdated
Comment thread protocols/docs/NOW-spec.md Outdated
`NowExecRunMsg::decode_from_body` discarded the header flags and rebuilt them
from payload content, making it the only exec message that did not preserve
flags on decode. Every other exec message seeds its flags from
`from_bits_retain(header.flags)`.

Any flag on NOW_EXEC_RUN_MSG other than DIRECTORY_SET was therefore silently
dropped by the decoder, so a server could not observe an option the client had
set. DIRECTORY_SET is still derived from the payload rather than trusted from
the header, because peers older than v1.1 omit the directory field entirely.

No test accompanies this on its own: NOW_EXEC_RUN_MSG has no second flag to
assert against yet. Coverage arrives with the first one added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The .NET client can send elevation flags without validating negotiated elevation support, allowing silent downgrade against older servers.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 26/26 changed files
  • Comments generated: 7
  • Review effort level: Balanced

Comment thread protocols/dotnet/Devolutions.NowClient/src/ExecBatchParams.cs
Comment thread protocols/dotnet/Devolutions.NowClient/src/ExecProcessParams.cs
Comment thread protocols/dotnet/Devolutions.NowClient/src/ExecPwshParams.cs
Comment thread protocols/dotnet/Devolutions.NowClient/src/ExecRunParams.cs
Comment thread protocols/dotnet/Devolutions.NowClient/src/ExecShellParams.cs
Comment thread protocols/dotnet/Devolutions.NowClient/src/ExecWinPsParams.cs
Comment thread protocols/docs/NOW-spec.md Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Batch NO_EXIT silently degrades against pre-1.7 servers, and its individual wire-bit allocations are not independently tested.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 28/28 changed files
  • Comments generated: 3
  • Review effort level: Balanced

Comment on lines +61 to +64
public ExecBatchParams NoExit(bool enable = true)
{
_noExit = enable;
return this;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The concern is real but I'd rather not fix it this way, because the remedy would make NoExit the only version-gated exec flag in the library while its exact siblings stay ungated.

State of the code today:

  • NowClient version-gates exactly one thing: the whole RDM extension (MIN_RDM_ENABLED_VERSION = 1.3, checked in EnsureRdmCapabilities).
  • It version-gates zero exec flags. DETACHED arrived in 1.4 with the identical property you describe — send it to a 1.3 server and the session is tracked anyway, silently contradicting the caller. RAW_ENCODING and UNICODE_CONSOLE arrived in 1.6; the latter got a capability bit, NOW_CAP_EXEC_UNICODE_CONSOLE, rather than a version check.

So the two coherent options are "the library gates no exec flags and callers gate" (status quo — RDM gates fire-and-forget on Version >= 1.4 and Unicode console on the capset bit) or "the library gates all of them, consistently". Gating just the newest flag is the one option that leaves the codebase harder to
reason about.

There's also a severity gap worth naming. Elevation silently not happening is a trust and correctness failure: the operator believes a script ran as administrator when it did not, and nothing in the UI or the logs says otherwise - that is the exact bug this PR exists to close, which is why it gets a hard refusal client-side. NoExit silently not applying means a console window closes instead of staying open: immediately visible to the person watching the remote desktop, no incorrect belief about privileges, no correctness impact. The enforcement asymmetry is proportionate to the consequence asymmetry.

Elevation also had a mechanism that a version can never express (shell vs token elevation differ in whether stdio survives), which is why it needed capability negotiation rather than a version compare. NoExit has no such dimension.

Concretely, my preference: leave the library as is, and have RDM gate the Keep open checkbox the same way it already gates Fire and forget. That is in the RDM-side plan for this work. If you'd rather have library-level enforcement, I think it deserves its own PR that covers DETACHED and the encoding flags too, so the rule is uniform.

Comment thread protocols/dotnet/Devolutions.NowProto.Tests/src/NowExecStyles.cs
Comment thread protocols/rust/now-proto-testsuite/tests/proto/exec.rs

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

quick checkup with some questions

///
/// NOW-PROTO: NOW_CAP_EXEC_ELEVATE_TOKEN
/// </summary>
ElevateToken = 0x0100,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's the difference between NOW_CAP_EXEC_ELEVATE_SHELL and NOW_CAP_EXEC_ELEVATE_TOKEN? what are the mechanics for dealing with elevated primary tokens over the wire? I guess I'll find out later in this PR

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ELEVATE_SHELL is what we'll support today. It's a capability downgrade (no I/O redirection, for instance) but can be done quite easily.

ELEVATE_TOKEN is just a reservation, no-one advertise or implements it as a capability now. But in the future, it allow us to offer an alternative approach where elevation is handled inside the service and we support the current I/O redirection (service will elevate without UAC prompt, I/O redirection by IPC into the user session / devolutions-session).

By adding both now, it just becomes a capability flip in the future.

///
/// NOW-PROTO: NOW_EXEC_FLAG_RUN_ELEVATED
/// </summary>
Elevated = 0x0002,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we have a proper mechanism for reporting errors related to trying to launch commands elevated, and it failed in some way? things like timeout from the UAC prompt, or the user denying the UAC prompt, etc.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partly. Existing code routes ShellExeute to NowStatusError::new_winapi(code) so a declined prompt for example comes back as ERROR_CANCELLED (1223).

We could improve the vocabulary because dealing with a Win32 error code client side is not very nice.

That said, there's no way to distinguish a timed out UAC prompt to a declined one. They're both "cancelled".

Updates NOW-PROTO to v1.7, adding the ability for a client to request that
an exec session run with elevated privileges.

- Add `NOW_EXEC_FLAG_*_ELEVATED` to the run, process, shell, batch, winps and
  pwsh exec messages. The flag expresses intent only and never names a
  mechanism, so the server remains free to change how it elevates. It raises
  the privileges of the identity that would otherwise run the command and
  carries no credentials; running a command as a different user is a separate
  concern for a separate field.
- Add `NOW_CAP_EXEC_ELEVATE_SHELL`, advertised by servers that elevate through
  the platform shell (consent prompt, no stdio redirection). It is the only
  elevation capability defined: a mechanism with different observable
  properties is expected to arrive as an additional capability rather than by
  redefining this one, so a client always learns what to expect before it sends.
- Refuse an elevated request in `NowClient` unless the negotiated capset
  advertises elevation. A server older than 1.7 does not know the flag and
  would execute the command without elevation, and the caller has no way to
  detect that, so the request is rejected locally rather than downgraded
  silently.
- Specify the mechanism-dependent semantics in a new "Elevated Execution"
  section: how elevation is requested, what it does and does not mean, that a
  server must fail rather than silently execute unelevated, that capability
  negotiation is the only reliable signal because older servers ignore the flag,
  and what the shell mechanism implies for stdio, result reporting and abort.
- Add `NOW_EXEC_FLAG_BATCH_NO_EXIT` (`cmd /K` rather than `/C`), so an elevated
  batch session, which has no stdio, can still leave its console open for the
  user to read. Must be ignored when the session redirects stdio. Exposed on
  `ExecBatchParams` alongside the existing winps and pwsh `NoExit` options.

The round-trip tests added here are also the first coverage of the RUN decoder
fix in the preceding commit: ELEVATED is the first NOW_EXEC_RUN_MSG flag that
would have been dropped by it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants