diff --git a/docs/api-reference/rust/v1/types.md b/docs/api-reference/rust/v1/types.md index e9af44da3..dd851a541 100644 --- a/docs/api-reference/rust/v1/types.md +++ b/docs/api-reference/rust/v1/types.md @@ -311,6 +311,10 @@ pub struct MxcProcess { } impl MxcProcess { + #[cfg(windows)] + pub fn duplicate_query_process_handle( + &self, + ) -> std::io::Result; pub fn warnings(&self) -> Vec; pub fn output_metadata(&self) -> Option<&ExecutionMetadata>; pub fn take_stdin(&mut self) -> Option>; @@ -329,6 +333,16 @@ impl MxcProcess { ``` +`duplicate_query_process_handle` is Windows-only and currently supported by +BaseContainer. Other Windows backends return +`std::io::ErrorKind::Unsupported`. It duplicates the retained process handle +without reopening a numeric process ID, with exactly +`PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE` access. The returned +`OwnedHandle` is non-inheritable and independently owned by the caller, who +closes it by dropping it. Process termination and sandbox cleanup remain MXC's +responsibility. This API is intentionally Rust-only; the .NET and Node bindings +do not expose it. + ## `mxc_sdk::v1::MxcPtyProcess` A live container process attached to a caller-controlled pseudo-terminal. diff --git a/src/mxc-sdk/README.md b/src/mxc-sdk/README.md index e12b066a8..a8768bd92 100644 --- a/src/mxc-sdk/README.md +++ b/src/mxc-sdk/README.md @@ -66,6 +66,27 @@ directional policy. For existing-container execution, `ExecutionRequest.network` is a runtime-only `ProcessNetworkPolicy`; it cannot change provision-time policy. +### Windows retained process queries + +On Windows, `MxcProcess` exposes this Rust-only operation: + +```rust,ignore +pub fn duplicate_query_process_handle( + &self, +) -> std::io::Result; +``` + +The operation duplicates the retained process handle without reopening a numeric +process ID. The duplicate is non-inheritable and has exactly +`PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE` access for identity queries and +waiting. BaseContainer currently supports it; other Windows backends return +`std::io::ErrorKind::Unsupported`. The method is unavailable on non-Windows +targets and is intentionally not exposed by the .NET or Node bindings. + +The caller owns the returned `OwnedHandle` independently and closes it by +dropping it. Keeping this query handle does not transfer process termination or +sandbox cleanup responsibility from MXC to the caller. + ## Lifecycle API Typed lifecycle operations are available under `v1`. `ProvisionRequest` diff --git a/src/mxc-sdk/src/backends/process_container/common/base_container_runner.rs b/src/mxc-sdk/src/backends/process_container/common/base_container_runner.rs index 27b83a786..4acc794f1 100644 --- a/src/mxc-sdk/src/backends/process_container/common/base_container_runner.rs +++ b/src/mxc-sdk/src/backends/process_container/common/base_container_runner.rs @@ -1752,7 +1752,48 @@ fn base_container_teardown_status( } } +fn duplicate_retained_query_process_handle( + process: HANDLE, +) -> std::io::Result { + use std::os::windows::io::FromRawHandle; + use windows::Win32::Foundation::{DuplicateHandle, DUPLICATE_HANDLE_OPTIONS}; + use windows::Win32::System::Threading::{ + GetCurrentProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, + }; + let mut duplicate = HANDLE::default(); + // SAFETY: the caller keeps the source process handle valid throughout + // this call. The new handle is noninheritable and has no mutation rights. + unsafe { + DuplicateHandle( + GetCurrentProcess(), + process, + GetCurrentProcess(), + &mut duplicate, + PROCESS_QUERY_LIMITED_INFORMATION.0 | PROCESS_SYNCHRONIZE.0, + false, + DUPLICATE_HANDLE_OPTIONS(0), + ) + } + .map_err(|error| { + // Only FACILITY_WIN32 HRESULTs embed a Win32 error in the low word. + // Preserve other facilities as HRESULT diagnostics instead of + // misreporting their low word as an unrelated OS error. + let hr = error.code().0 as u32; + if (hr & 0xFFFF_0000) == 0x8007_0000 { + std::io::Error::from_raw_os_error((hr & 0xFFFF) as i32) + } else { + std::io::Error::other(error.to_string()) + } + })?; + // SAFETY: DuplicateHandle created a distinct owned kernel handle. + Ok(unsafe { std::os::windows::io::OwnedHandle::from_raw_handle(duplicate.0) }) +} + impl SandboxProcess for BaseContainerSandboxProcess { + fn duplicate_query_process_handle(&self) -> std::io::Result { + duplicate_retained_query_process_handle(self.process.get()) + } + fn output_metadata(&self) -> Option<&SandboxOutputMetadata> { self.output_metadata.as_ref() } @@ -2076,6 +2117,77 @@ mod tests { use crate::process_security_environment_spec::process_security_environment_layout as psec_layout; use std::sync::atomic::{AtomicUsize, Ordering}; + #[test] + fn native_query_process_duplicate_is_noninheritable_and_independently_owned() { + use std::os::windows::io::AsRawHandle; + use windows::Win32::Foundation::{GetHandleInformation, NTSTATUS}; + use windows::Win32::System::LibraryLoader::{GetModuleHandleW, GetProcAddress}; + use windows::Win32::System::Threading::{ + GetCurrentProcess, GetCurrentProcessId, GetProcessId, + PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, + }; + + // NtQueryObject(ObjectBasicInformation) is a read-only query of the + // duplicate's granted mask. It avoids attempting a mutating operation + // against the test process to prove the absence of broader rights. + #[repr(C)] + #[derive(Default)] + struct PublicObjectBasicInformation { + _attributes: u32, + granted_access: u32, + _handle_count: u32, + _pointer_count: u32, + _reserved: [u32; 10], + } + type NtQueryObjectFn = + unsafe extern "system" fn(HANDLE, u32, *mut c_void, u32, *mut u32) -> NTSTATUS; + + // The current-process pseudo-handle is an inert source: no child, + // container, profile or host permission is created or changed. + let original = duplicate_retained_query_process_handle(unsafe { GetCurrentProcess() }) + .expect("duplicate current process for query"); + let survivor = duplicate_retained_query_process_handle(HANDLE(original.as_raw_handle())) + .expect("duplicate retained query handle"); + assert_ne!(original.as_raw_handle(), survivor.as_raw_handle()); + drop(original); + let handle = HANDLE(survivor.as_raw_handle()); + assert_eq!(unsafe { GetProcessId(handle) }, unsafe { + GetCurrentProcessId() + }); + let mut flags = 0; + unsafe { GetHandleInformation(handle, &mut flags) }.expect("query duplicate flags"); + assert_eq!(flags & HANDLE_FLAG_INHERIT.0, 0); + // SAFETY: ntdll is already loaded; the export has the documented + // NtQueryObject signature and the stack output buffer is fully sized. + let module = + unsafe { GetModuleHandleW(windows::core::w!("ntdll.dll")) }.expect("loaded ntdll"); + let export = unsafe { GetProcAddress(module, windows::core::s!("NtQueryObject")) } + .expect("NtQueryObject export"); + let query: NtQueryObjectFn = unsafe { std::mem::transmute(export) }; + assert_eq!(std::mem::size_of::(), 56); + let mut basic = PublicObjectBasicInformation::default(); + let mut returned = 0u32; + let status = unsafe { + query( + handle, + 0, // ObjectBasicInformation + (&mut basic as *mut PublicObjectBasicInformation).cast(), + std::mem::size_of::() as u32, + &mut returned, + ) + }; + assert_eq!(status.0, 0, "query duplicate granted access"); + assert_eq!( + returned as usize, + std::mem::size_of::() + ); + assert_eq!( + basic.granted_access, + PROCESS_QUERY_LIMITED_INFORMATION.0 | PROCESS_SYNCHRONIZE.0 + ); + assert_eq!(unsafe { WaitForSingleObject(handle, 0) }, WAIT_TIMEOUT); + } + #[test] fn native_capture_probe_requires_base_container_and_complete_learning_mode_abi() { assert!(!BaseContainerRunner::native_capture_available_with( diff --git a/src/mxc-sdk/src/backends/process_container/common/dispatcher.rs b/src/mxc-sdk/src/backends/process_container/common/dispatcher.rs index 322cfcf5c..cfe22f91f 100644 --- a/src/mxc-sdk/src/backends/process_container/common/dispatcher.rs +++ b/src/mxc-sdk/src/backends/process_container/common/dispatcher.rs @@ -748,6 +748,10 @@ struct DaclGuardedProcess { } impl SandboxProcess for DaclGuardedProcess { + fn duplicate_query_process_handle(&self) -> std::io::Result { + self.inner.duplicate_query_process_handle() + } + fn warnings(&self) -> Vec { self.inner.warnings() } @@ -1439,6 +1443,12 @@ mod tests { output_metadata: crate::mxc_common::models::SandboxOutputMetadata, } impl SandboxProcess for FakeProcess { + fn duplicate_query_process_handle( + &self, + ) -> std::io::Result { + Err(std::io::Error::from_raw_os_error(12345)) + } + fn take_stdin(&mut self) -> Option> { self.stdin_taken = true; None @@ -1491,6 +1501,13 @@ mod tests { }; assert_eq!(guarded.id(), 4242, "id() must delegate"); + assert_eq!( + guarded + .duplicate_query_process_handle() + .unwrap_err() + .raw_os_error(), + Some(12345) + ); assert!( matches!(guarded.try_wait(), Ok(Some(7))), "try_wait() must delegate" diff --git a/src/mxc-sdk/src/core/mxc_common/sandbox_process.rs b/src/mxc-sdk/src/core/mxc_common/sandbox_process.rs index bb0d530b5..66c870a26 100644 --- a/src/mxc-sdk/src/core/mxc_common/sandbox_process.rs +++ b/src/mxc-sdk/src/core/mxc_common/sandbox_process.rs @@ -147,6 +147,22 @@ pub fn duplicate_and_take_native_stdio( /// before touching the other can hang on output-heavy children. Taking only /// one stream (leaving the other for `wait()` to drain) is always safe. pub trait SandboxProcess: Send { + /// Duplicate a retained Windows process handle with query and wait access. + /// + /// Supporting backends return a non-inheritable handle to the exact process + /// object they already own, with only `PROCESS_QUERY_LIMITED_INFORMATION` + /// and `SYNCHRONIZE`. Implementations must not reopen the numeric process ID. + /// The caller owns the duplicate; dropping it does not terminate the process + /// or change the sandbox's cleanup responsibility. Unsupported backends + /// return `ErrorKind::Unsupported`. Initially provided by BaseContainer. + #[cfg(windows)] + fn duplicate_query_process_handle(&self) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "this backend does not expose an owned native query process handle", + )) + } + /// Warnings associated with this sandbox, such as a policy that /// intentionally relaxes containment, an unavailable telemetry route, or a /// cleanup step that failed after the process exited. @@ -869,6 +885,15 @@ mod runner_tests { } } + #[cfg(windows)] + #[test] + fn native_query_process_handle_defaults_to_unsupported_without_os_calls() { + let error = CompletedProcess + .duplicate_query_process_handle() + .expect_err("unsupported process must not expose native authority"); + assert_eq!(error.kind(), std::io::ErrorKind::Unsupported); + } + struct CountingBackend { validations: Arc, direct_spawns: Arc, diff --git a/src/mxc-sdk/src/core/mxc_engine/mod.rs b/src/mxc-sdk/src/core/mxc_engine/mod.rs index aa1b4a62e..467194c89 100644 --- a/src/mxc-sdk/src/core/mxc_engine/mod.rs +++ b/src/mxc-sdk/src/core/mxc_engine/mod.rs @@ -585,6 +585,11 @@ impl Drop for TelemetryProcess { } impl SandboxProcess for TelemetryProcess { + #[cfg(windows)] + fn duplicate_query_process_handle(&self) -> std::io::Result { + self.inner.duplicate_query_process_handle() + } + /// Merged at the call rather than when this wrapper was built: the wrapped /// process can report a cleanup failure long after that. fn warnings(&self) -> Vec { @@ -747,6 +752,11 @@ impl ProcessWithWarnings { } impl SandboxProcess for ProcessWithWarnings { + #[cfg(windows)] + fn duplicate_query_process_handle(&self) -> std::io::Result { + self.inner.duplicate_query_process_handle() + } + /// Merged at the call rather than when this wrapper was built: the wrapped /// process can report a cleanup failure long after that. fn warnings(&self) -> Vec { @@ -877,6 +887,13 @@ mod telemetry_process_tests { } impl SandboxProcess for NativeStdioProbe { + #[cfg(windows)] + fn duplicate_query_process_handle( + &self, + ) -> std::io::Result { + Err(std::io::Error::from_raw_os_error(12345)) + } + fn take_native_stdio(&mut self) -> std::io::Result> { self.calls.fetch_add(1, Ordering::SeqCst); Ok(None) @@ -1042,6 +1059,14 @@ mod telemetry_process_tests { }, std::time::Instant::now(), ); + #[cfg(windows)] + assert_eq!( + telemetry + .duplicate_query_process_handle() + .unwrap_err() + .raw_os_error(), + Some(12345) + ); assert!(telemetry.take_native_stdio().unwrap().is_none()); assert!(telemetry.stdin_closer().is_none()); let (_, telemetry_pty_closer) = telemetry.pty_clone_reader_with_closer().unwrap(); @@ -1068,6 +1093,14 @@ mod telemetry_process_tests { }), vec!["test warning".to_string()], ); + #[cfg(windows)] + assert_eq!( + with_warnings + .duplicate_query_process_handle() + .unwrap_err() + .raw_os_error(), + Some(12345) + ); assert!(with_warnings.take_native_stdio().unwrap().is_none()); assert!(with_warnings.stdin_closer().is_none()); let (_, warning_pty_closer) = with_warnings.pty_clone_reader_with_closer().unwrap(); diff --git a/src/mxc-sdk/src/sandbox.rs b/src/mxc-sdk/src/sandbox.rs index 78c2a9353..21c90699a 100644 --- a/src/mxc-sdk/src/sandbox.rs +++ b/src/mxc-sdk/src/sandbox.rs @@ -273,6 +273,21 @@ enum StdioAccess { } impl MxcProcess { + /// Duplicate this process's retained Windows handle for identity queries + /// and waiting, without reopening a numeric process ID. + /// + /// The non-inheritable duplicate has only + /// `PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE` access. The caller owns + /// it independently of this object, but the sandbox still owns process + /// termination and cleanup. Initially supported by BaseContainer; other + /// backends may return `std::io::ErrorKind::Unsupported`. + #[cfg(windows)] + pub fn duplicate_query_process_handle( + &self, + ) -> std::io::Result { + self.inner.duplicate_query_process_handle() + } + pub(crate) fn new(inner: Box) -> Self { Self { inner, @@ -789,6 +804,13 @@ mod tests { struct NativeOnlyFake; impl SandboxProcess for NativeOnlyFake { + #[cfg(windows)] + fn duplicate_query_process_handle( + &self, + ) -> std::io::Result { + Err(std::io::Error::from_raw_os_error(12345)) + } + fn warnings(&self) -> Vec { Vec::new() } @@ -830,6 +852,27 @@ mod tests { } } + #[cfg(windows)] + #[test] + fn native_query_process_handle_is_independent_of_stdio_transfer() { + let mut process = MxcProcess::new(Box::new(NativeOnlyFake)); + assert_eq!( + process + .duplicate_query_process_handle() + .unwrap_err() + .raw_os_error(), + Some(12345) + ); + let _stdio = process.take_native_stdio().expect("native stdio transfer"); + assert_eq!( + process + .duplicate_query_process_handle() + .unwrap_err() + .raw_os_error(), + Some(12345) + ); + } + impl SandboxProcess for FakeProcess { fn warnings(&self) -> Vec { self.warnings.clone()