Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 14 additions & 0 deletions docs/api-reference/rust/v1/types.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,10 @@ pub struct MxcProcess {
}

impl MxcProcess {
#[cfg(windows)]
pub fn duplicate_query_process_handle(
&self,
) -> std::io::Result<std::os::windows::io::OwnedHandle>;
pub fn warnings(&self) -> Vec<String>;
pub fn output_metadata(&self) -> Option<&ExecutionMetadata>;
pub fn take_stdin(&mut self) -> Option<Box<dyn Write + Send>>;
Expand All @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions src/mxc-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::os::windows::io::OwnedHandle>;
```

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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1752,7 +1752,48 @@ fn base_container_teardown_status(
}
}

fn duplicate_retained_query_process_handle(
process: HANDLE,
) -> std::io::Result<std::os::windows::io::OwnedHandle> {
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<std::os::windows::io::OwnedHandle> {
duplicate_retained_query_process_handle(self.process.get())
}

fn output_metadata(&self) -> Option<&SandboxOutputMetadata> {
self.output_metadata.as_ref()
}
Expand Down Expand Up @@ -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::<PublicObjectBasicInformation>(), 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::<PublicObjectBasicInformation>() as u32,
&mut returned,
)
};
assert_eq!(status.0, 0, "query duplicate granted access");
assert_eq!(
returned as usize,
std::mem::size_of::<PublicObjectBasicInformation>()
);
assert_eq!(
basic.granted_access,
PROCESS_QUERY_LIMITED_INFORMATION.0 | PROCESS_SYNCHRONIZE.0
);
assert_eq!(unsafe { WaitForSingleObject(handle, 0) }, WAIT_TIMEOUT);
Comment thread
Copilot marked this conversation as resolved.
}

#[test]
fn native_capture_probe_requires_base_container_and_complete_learning_mode_abi() {
assert!(!BaseContainerRunner::native_capture_available_with(
Expand Down
17 changes: 17 additions & 0 deletions src/mxc-sdk/src/backends/process_container/common/dispatcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,10 @@ struct DaclGuardedProcess {
}

impl SandboxProcess for DaclGuardedProcess {
fn duplicate_query_process_handle(&self) -> std::io::Result<std::os::windows::io::OwnedHandle> {
self.inner.duplicate_query_process_handle()
}

fn warnings(&self) -> Vec<String> {
self.inner.warnings()
}
Expand Down Expand Up @@ -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<std::os::windows::io::OwnedHandle> {
Err(std::io::Error::from_raw_os_error(12345))
}

fn take_stdin(&mut self) -> Option<Box<dyn Write + Send>> {
self.stdin_taken = true;
None
Expand Down Expand Up @@ -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"
Expand Down
25 changes: 25 additions & 0 deletions src/mxc-sdk/src/core/mxc_common/sandbox_process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,22 @@ pub fn duplicate_and_take_native_stdio<I, O, E>(
/// 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<std::os::windows::io::OwnedHandle> {
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.
Expand Down Expand Up @@ -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<AtomicUsize>,
direct_spawns: Arc<AtomicUsize>,
Expand Down
33 changes: 33 additions & 0 deletions src/mxc-sdk/src/core/mxc_engine/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,11 @@ impl Drop for TelemetryProcess {
}

impl SandboxProcess for TelemetryProcess {
#[cfg(windows)]
fn duplicate_query_process_handle(&self) -> std::io::Result<std::os::windows::io::OwnedHandle> {
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<String> {
Expand Down Expand Up @@ -747,6 +752,11 @@ impl ProcessWithWarnings {
}

impl SandboxProcess for ProcessWithWarnings {
#[cfg(windows)]
fn duplicate_query_process_handle(&self) -> std::io::Result<std::os::windows::io::OwnedHandle> {
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<String> {
Expand Down Expand Up @@ -877,6 +887,13 @@ mod telemetry_process_tests {
}

impl SandboxProcess for NativeStdioProbe {
#[cfg(windows)]
fn duplicate_query_process_handle(
&self,
) -> std::io::Result<std::os::windows::io::OwnedHandle> {
Err(std::io::Error::from_raw_os_error(12345))
}

fn take_native_stdio(&mut self) -> std::io::Result<Option<NativeStdio>> {
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(None)
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand Down
43 changes: 43 additions & 0 deletions src/mxc-sdk/src/sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::os::windows::io::OwnedHandle> {
self.inner.duplicate_query_process_handle()
}

pub(crate) fn new(inner: Box<dyn SandboxProcess>) -> Self {
Self {
inner,
Expand Down Expand Up @@ -789,6 +804,13 @@ mod tests {
struct NativeOnlyFake;

impl SandboxProcess for NativeOnlyFake {
#[cfg(windows)]
fn duplicate_query_process_handle(
&self,
) -> std::io::Result<std::os::windows::io::OwnedHandle> {
Err(std::io::Error::from_raw_os_error(12345))
}

fn warnings(&self) -> Vec<String> {
Vec::new()
}
Expand Down Expand Up @@ -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<String> {
self.warnings.clone()
Expand Down