Skip to content

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

Merged
Marc-André Moreau (mamoreau-devolutions) merged 6 commits into
masterfrom
feat/exec-elevation
Oct 30, 2026
Merged

Marc-André Moreau (mamoreau-devolutions) merged 6 commits into
masterfrom
feat/exec-elevation

Conversation

@thenextman

@thenextman Richard Markiewicz (thenextman) commented Sep 9, 2026 •

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 and both client libraries.

What this adds

  • NOW_EXEC_FLAG_*_ELEVATED on the run, process, shell, batch, winps and pwsh exec messages.
  • NOW_CAP_EXEC_ELEVATION (0x0080) — advertised by servers that can run elevated exec sessions. The baseline semantics are the most restrictive a platform offers: it may prompt the interactive user for consent, and stdio is not redirected.
  • NOW_CODE_ELEVATION_DENIED (0x0009) and NOW_CODE_ELEVATION_UNAVAILABLE (0x000A) — NOW-level status codes so a client never has to sniff Win32 numbers to learn why elevation failed.
  • NOW_EXEC_FLAG_BATCH_NO_EXIT (0x0010) — cmd /K rather than /C. Under elevation there is no stdio, so a console left open is the only way the user sees output. winps/pwsh already had NO_EXIT; batch did not. Must be ignored when the session redirects stdio, where the hidden interpreter would never exit.
  • A new normative "Elevated Execution" spec section.
  • Client-side refusal in both Devolutions.NowClient and the Rust now-client: an elevated request is rejected unless the negotiated capset advertises elevation. The .NET client also refuses batch NoExit to a pre-1.7 peer.

Design: intent, not mechanism

The part worth reviewing. Three rules, all aimed at letting a better elevation 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 server advertises what elevation costs in execCapset, the same way UNICODE_CONSOLE and IO_REDIRECTION already do. A client learns what to expect before sending.
  3. The capability is named by property, not mechanism. An earlier revision called it ELEVATE_SHELL. Clients only ever need to answer two questions — will it prompt? and will I get stdio? — and a mechanism-named bit makes them keep a table of mechanisms as more arrive (a PEDM token path with no prompt and stdio; macOS osascript … with administrator privileges with a prompt and stdio). NOW_CAP_EXEC_ELEVATION is the conservative baseline; a future bit relaxes exactly one property and says which. That matches the existing capset, where every bit names what the host supports rather than how.

Two scoping statements in the spec keep future work unblocked:

  • ELEVATED raises the privileges of the identity that would otherwise run the command. It selects no identity and carries no credentials, so "run as another user" has clean ground for a separate field. The spec is honest that credential-prompt elevation (a standard user typing an admin's password) runs the command as that account, because the identity sentence is what "run as" will later be built on.
  • An elevation capability means the server implements elevation, not that every advertised exec style can be elevated. A server that cannot elevate a style fails with ELEVATION_UNAVAILABLE; a client must handle it per style.

Three things the section now pins down because they otherwise become support tickets:

  • NOW_EXEC_STARTED_MSG is sent after elevation is granted. The UAC consent wait (up to 120 s) happens before it, so clients should start execution timers at STARTED, not at submission.
  • An elevated session sends no EXEC_DATA at all, and a server must ignore IO_REDIRECTION set alongside ELEVATED, so no client waits on a stream EOF that never comes.
  • Abort and cancel may fail (the child runs at a higher integrity level) and cannot dismiss a pending prompt.

Why the client refuses locally

The spec requires a server to fail rather than execute unelevated — but that only binds servers at 1.7 or later. A pre-1.7 server does not know the flag: it ignores the unknown bit and runs the command with normal privileges, with nothing on the wire to distinguish that from success. The server-side MUST cannot protect that case, so both clients refuse the request when no elevation capability was advertised. Silent downgrade is the exact defect this PR exists to remove; it would be perverse to reintroduce it in the client.

The Rust now-client additionally rejects an elevated request carrying inline stdin or a timeout: there is no stdio to write to, and a timeout is enforced by cancellation, which may fail.

Bit allocation

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

Message Flag Value
NOW_CHANNEL_CAPSET_MSG (execCapset) NOW_CAP_EXEC_ELEVATION 0x0080
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
NOW_STATUS (NOW_STATUS_ERROR_KIND_NOW) NOW_CODE_ELEVATION_DENIED 0x0009
NOW_STATUS (NOW_STATUS_ERROR_KIND_NOW) NOW_CODE_ELEVATION_UNAVAILABLE 0x000A

Included bug fix (separate commit)

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, so a server would never have observed the request. DIRECTORY_SET is still derived from the payload, because peers older than v1.1 omit the directory field entirely.

Split out so it gets its own Bug Fixes changelog entry: it is a latent bug in released 0.4.4, independent of this feature.

Verified behaviour behind the spec

The section says an elevated session SHOULD still report its real exit code despite having no stdio. Measured rather than assumed: from a Medium IL (S-1-16-8192) unelevated caller, a ShellExecuteEx/runas child at High IL (S-1-16-12288):

WaitForExit : ok    [SYNCHRONIZE granted]
Id          : ok    [PROCESS_QUERY_LIMITED_INFORMATION granted]
ExitCode    : 42    [true value, not a default 0]

MIC's no-read-up rule is enforced when opening a handle; here the handle is created for us by the AppInfo service, so it does not apply at use. PROCESS_TERMINATE is untested, which is why abort stays MAY-fail in the spec.

Testing

  • Round-trip tests across both languages for ELEVATED on every exec style, ELEVATED / NO_EXIT asserted independently and together, and EXEC_RESULT carrying each new status code. The .NET byte arrays are the exact encodings the Rust suite asserts.
  • Rust now-client: an elevated request is refused without the capability and nothing reaches the peer; with it, the request carries ELEVATED without IO_REDIRECTION and completes with the real exit code; a timeout on an elevated request is rejected.
  • Devolutions.NowClient.Tests: the same refusal, the wire shape of an elevated NoExit batch, and NoExit refused on a 1.6 peer while a plain batch still goes through.
  • Gates: rust fmt, rust lints, rust tests, dotnet fmt, dotnet tests (net10), check typos, check locks — all green. (net9 test runs need a runtime not installed on the dev box; CI has it.)

Resolved design questions

  • Elevation error codes — added. Deferring would have let interim clients hardcode ERROR_CANCELLED (1223), which outlives the fix. Denial and consent timeout are indistinguishable at the API level, so "consent not granted" is the granularity.
  • NO_EXIT discovery — version-implied, matching DETACHED (1.4). The rule as applied: flags whose support varies by host get a capability bit; flags that are pure protocol behaviour are version-implied and gated client-side.
  • No supports_exec_elevation() version helper — deliberate. Elevation must be gated on the capability, not the version: a 1.7 Linux agent cannot runas, and a 1.7 Windows agent that has not shipped the host-side change cannot either.
  • No best-effort mode. Elevation is strict-or-fail. Strict is recoverable toward leniency later; the reverse breaks callers.

Still open

  • Reconcile with the policies/ elevation model? This repo already has an Elevation enum (Standard/Elevated), RequestedElevation and ElevationRequired under policies/. Not reconciled here; "elevate this exec session, subject to PEDM policy" will eventually want them to compose, and ELEVATION_DENIED is where a policy denial would land.
  • NOW-spec.pdf not regenerated. The PDF is untracked.

Follow-up work

  1. devolutions-gateway — honour ELEVATED for run, process, batch, winps and pwsh in one release, so "the capability does not cover every style" stays a safety valve and never something RDM users discover by running a script. The existing win_api_wrappers::process::shell_execute is the launch path; it needs SEE_MASK_NOASYNC, and the blocking runas call must leave the DVC message loop. Map ERROR_CANCELLED to ELEVATION_DENIED and advertise NOW_CAP_EXEC_ELEVATION.
  2. RDM — send the flag, gate the checkbox on the capability, state in the UI that elevated runs have no output capture and will raise a UAC prompt in the remote session.

Copilot AI balanced review requested due to automatic review settings September 9, 2026 19:21

Copilot AI left a comment

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.

🟡 Changes recommended

Batch NoExit is not gated against pre-1.7 servers, allowing silent fallback to cmd /C.

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

Pull request overview

Adds NOW-PROTO 1.7 elevated execution support across Rust and .NET implementations.

Changes:

  • Adds elevated-execution flags and shell-elevation capability negotiation.
  • Adds batch NO_EXIT support and fixes Rust run-flag decoding.
  • Updates wire-format tests, client APIs, and protocol documentation.
File summaries
File Description
protocols/rust/now-proto-testsuite/tests/proto/exec.rs Tests new 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 flags.
protocols/rust/now-proto-pdu/src/channel/capset.rs Adds capability and protocol version.
protocols/dotnet/Devolutions.NowProto/src/NowProtoVersion.cs Updates protocol version.
protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecWinPs.cs Adds WinPS elevation serialization.
protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecShell.cs Adds shell elevation serialization.
protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecRun.cs Adds run elevation serialization.
protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecPwsh.cs Adds pwsh elevation serialization.
protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecProcess.cs Adds process elevation serialization.
protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecBatch.cs Adds batch elevation and no-exit serialization.
protocols/dotnet/Devolutions.NowProto/src/Capabilities/NowCapabilityExec.cs Defines shell-elevation capability.
protocols/dotnet/Devolutions.NowProto.Tests/src/NowExecStyles.cs Tests new .NET wire encodings.
protocols/dotnet/Devolutions.NowProto.Tests/src/MsgChannel.cs Updates version snapshots.
protocols/dotnet/Devolutions.NowClient/src/NowClient.cs Rejects unsupported elevation requests.
protocols/dotnet/Devolutions.NowClient/src/ExecWinPsParams.cs Exposes WinPS elevation option.
protocols/dotnet/Devolutions.NowClient/src/ExecShellParams.cs Exposes shell elevation option.
protocols/dotnet/Devolutions.NowClient/src/ExecRunParams.cs Exposes run elevation option.
protocols/dotnet/Devolutions.NowClient/src/ExecPwshParams.cs Exposes pwsh elevation option.
protocols/dotnet/Devolutions.NowClient/src/ExecProcessParams.cs Exposes process elevation option.
protocols/dotnet/Devolutions.NowClient/src/ExecBatchParams.cs Exposes elevation and no-exit options.
protocols/dotnet/Devolutions.NowClient/src/AExecParams.cs Tracks elevation intent.
protocols/docs/NOW-spec.md Documents NOW-PROTO 1.7 semantics.
Review details
  • Files reviewed: 28/28 changed files
  • Comments generated: 1
  • 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 on lines +61 to +64
public ExecBatchParams NoExit(bool enable = true)
{
_noExit = enable;
return this;
`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>
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>
A server that cannot grant elevation must report it in a form a client
can act on without parsing platform error numbers, which mean nothing on
POSIX and cannot be told apart from a future policy denial.

`NOW_CODE_ELEVATION_DENIED` (0x0009) covers consent that was declined,
expired, or denied by policy; `NOW_CODE_ELEVATION_UNAVAILABLE` (0x000A)
covers an account with no elevated token or a style the server cannot
elevate. Round-trip tests pin both languages to the same encoding.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Rename `NOW_CAP_EXEC_ELEVATE_SHELL` to `NOW_CAP_EXEC_ELEVATION`, following
`IO_REDIRECTION` and `UNICODE_CONSOLE`, which name what the host supports
rather than how. Clients need to answer two questions, "will it prompt"
and "will I get stdio", and a mechanism-named bit forces them to keep a
table of mechanisms as more arrive; a property-named baseline plus
future bits that each relax one property keeps that logic flat.

The spec section now states the baseline properties, when `STARTED`
arrives relative to the consent wait so client timers do not fire on a
slow user, that an elevated session sends no `EXEC_DATA`, which NOW
status code each failure maps to, and that credential-prompt elevation
runs the command as the supplied account.

The .NET client also gates batch `NoExit` on 1.7, since a pre-1.7 peer
would silently run `cmd /C`, and gains tests for both refusals and for
the wire shape of an elevated request.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Every request type gains `with_elevated()`. The client refuses it unless
the peer negotiated `NOW_CAP_EXEC_ELEVATION`, because a pre-1.7 peer
ignores the flag and runs the command unelevated with nothing on the wire
to show it. An elevated tracked request omits `IO_REDIRECTION` and
rejects inline stdin and a timeout: it has no stdio, and its cancellation
may fail, so a timeout could not be enforced.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@thenextman Richard Markiewicz (thenextman) changed the title feat: add elevated execution support to exec messages feat: add elevated execution support to exec messages Oct 21, 2026
…d started flag

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@mamoreau-devolutions
Marc-André Moreau (mamoreau-devolutions) marked this pull request as ready for review October 28, 2026 18:45
@mamoreau-devolutions
Marc-André Moreau (mamoreau-devolutions) merged commit 4a24d1e into master Oct 30, 2026
9 checks passed
@mamoreau-devolutions
Marc-André Moreau (mamoreau-devolutions) deleted the feat/exec-elevation branch October 30, 2026 13:35
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