Skip to main content

winx_code_agent/
os.rs

1//! Narrow operating-system boundary for audited libc and Win32 process calls.
2
3#![allow(unsafe_code)]
4
5#[cfg(unix)]
6pub(crate) mod unix {
7    use std::io;
8
9    pub(crate) fn effective_uid() -> u32 {
10        // SAFETY: geteuid has no preconditions and reads process credentials only.
11        unsafe { libc::geteuid() }
12    }
13
14    /// Take an exclusive, non-blocking advisory lock on `file`. Returns
15    /// `Ok(false)` when another process holds it. The lock lives as long as
16    /// the file descriptor, so callers keep the `File` open to stay locked.
17    pub(crate) fn try_lock_exclusive(file: &std::fs::File) -> io::Result<bool> {
18        use std::os::unix::io::AsRawFd as _;
19        // SAFETY: the descriptor belongs to a live `File` borrowed for the call.
20        if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 {
21            return Ok(true);
22        }
23        let error = io::Error::last_os_error();
24        if error.kind() == io::ErrorKind::WouldBlock {
25            return Ok(false);
26        }
27        Err(error)
28    }
29
30    /// Return whether a positive process ID still names a live process. EPERM
31    /// means the process exists but is not signalable by this caller.
32    pub(crate) fn process_exists(pid: u32) -> bool {
33        let Ok(pid) = i32::try_from(pid) else { return false };
34        if pid <= 1 {
35            return false;
36        }
37        // SAFETY: signal 0 performs permission/existence checking only and uses
38        // no pointers.
39        if unsafe { libc::kill(pid, 0) } == 0 {
40            return true;
41        }
42        io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
43    }
44
45    /// Signal one process. A process that has already exited is treated as a
46    /// successful cleanup operation.
47    pub(crate) fn signal_process(pid: u32, signal: i32) -> io::Result<()> {
48        let pid = i32::try_from(pid)
49            .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "pid does not fit pid_t"))?;
50        signal_raw(pid, signal)
51    }
52
53    pub(crate) fn signal_raw(pid: i32, signal: i32) -> io::Result<()> {
54        // SAFETY: kill(2) takes integer process/signal identifiers and no pointers.
55        if unsafe { libc::kill(pid, signal) } == 0 {
56            return Ok(());
57        }
58        let error = io::Error::last_os_error();
59        if error.raw_os_error() == Some(libc::ESRCH) {
60            Ok(())
61        } else {
62            Err(error)
63        }
64    }
65
66    /// Resolve a process group only when the child is its own group leader.
67    pub(crate) fn owned_process_group(pid: u32) -> Option<i32> {
68        let pid = i32::try_from(pid).ok()?;
69        if pid <= 1 {
70            return None;
71        }
72        // SAFETY: getpgid reads kernel metadata for an integer pid and returns -1
73        // on error; no memory is accessed through pointers.
74        let group = unsafe { libc::getpgid(pid) };
75        (group == pid).then_some(group)
76    }
77
78    pub(crate) fn signal_group(group: i32, signal: i32) -> io::Result<()> {
79        if group <= 1 {
80            return Err(io::Error::new(
81                io::ErrorKind::InvalidInput,
82                "process group must be greater than 1",
83            ));
84        }
85        signal_raw(-group, signal)
86    }
87
88    /// Configure a child command to become its own session immediately after
89    /// fork and before exec.
90    pub(crate) fn configure_detached(command: &mut std::process::Command) {
91        use std::os::unix::process::CommandExt as _;
92        // SAFETY: the hook invokes only the async-signal-safe `create_session`
93        // helper and touches no allocator-backed state between fork and exec.
94        unsafe {
95            command.pre_exec(create_session);
96        }
97    }
98
99    /// Async-signal-safe child setup hook used immediately after fork and before
100    /// exec by `Command::pre_exec`.
101    fn create_session() -> io::Result<()> {
102        // SAFETY: setsid has no pointer arguments and is async-signal-safe.
103        if unsafe { libc::setsid() } == -1 {
104            Err(io::Error::last_os_error())
105        } else {
106            Ok(())
107        }
108    }
109
110    #[cfg(test)]
111    mod tests {
112        #[test]
113        fn current_process_exists_and_reserved_ids_are_rejected() {
114            assert!(super::process_exists(std::process::id()));
115            assert!(!super::process_exists(0));
116            assert!(!super::process_exists(1));
117        }
118    }
119}
120
121/// Whether a process with `pid` is alive, on every supported platform.
122/// Process-wide hardening applied at the top of every binary's `main`.
123/// On Windows it narrows the DLL search path (see
124/// `windows::restrict_dll_search_path`); Unix has nothing equivalent to do.
125pub fn harden_process() {
126    #[cfg(windows)]
127    windows::restrict_dll_search_path();
128}
129
130pub(crate) fn process_exists(pid: u32) -> bool {
131    #[cfg(unix)]
132    {
133        unix::process_exists(pid)
134    }
135    #[cfg(windows)]
136    {
137        windows::process_exists(pid)
138    }
139    #[cfg(not(any(unix, windows)))]
140    {
141        let _ = pid;
142        false
143    }
144}
145
146#[cfg(windows)]
147pub(crate) mod windows {
148    use std::ffi::c_void;
149    use std::io;
150
151    use windows_sys::Win32::Foundation::{
152        CloseHandle, LocalFree, SetHandleInformation, ERROR_ACCESS_DENIED, HANDLE,
153        HANDLE_FLAG_INHERIT, INVALID_HANDLE_VALUE, STILL_ACTIVE,
154    };
155    use windows_sys::Win32::Security::Authorization::{
156        ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW,
157        SDDL_REVISION_1,
158    };
159    use windows_sys::Win32::Security::{
160        GetTokenInformation, TokenElevation, TokenUser, SECURITY_ATTRIBUTES, TOKEN_ELEVATION,
161        TOKEN_QUERY, TOKEN_USER,
162    };
163    use windows_sys::Win32::System::Console::{
164        GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE,
165    };
166    use windows_sys::Win32::System::JobObjects::{
167        AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
168        SetInformationJobObject, TerminateJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
169        JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
170    };
171    use windows_sys::Win32::System::LibraryLoader::{
172        SetDefaultDllDirectories, LOAD_LIBRARY_SEARCH_DEFAULT_DIRS,
173    };
174    use windows_sys::Win32::System::Threading::{
175        GetCurrentProcess, GetExitCodeProcess, OpenProcess, OpenProcessToken, TerminateProcess,
176        CREATE_BREAKAWAY_FROM_JOB, CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW, DETACHED_PROCESS,
177        PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SET_QUOTA, PROCESS_TERMINATE,
178    };
179
180    /// Kernel handle closed on drop.
181    struct OwnedHandle(HANDLE);
182
183    // SAFETY: a Win32 handle is a process-wide token, not thread-affine memory;
184    // it may be used and closed from any thread.
185    unsafe impl Send for OwnedHandle {}
186    // SAFETY: the job/process calls made through the handle are thread-safe.
187    unsafe impl Sync for OwnedHandle {}
188
189    impl Drop for OwnedHandle {
190        fn drop(&mut self) {
191            // SAFETY: the handle was returned by a successful Win32 call and is
192            // closed exactly once here.
193            unsafe {
194                CloseHandle(self.0);
195            }
196        }
197    }
198
199    fn open_process(pid: u32, access: u32) -> io::Result<OwnedHandle> {
200        // SAFETY: OpenProcess takes integer arguments only and returns a null
201        // handle on failure, which is checked before use.
202        let handle = unsafe { OpenProcess(access, 0, pid) };
203        if handle.is_null() {
204            Err(io::Error::last_os_error())
205        } else {
206            Ok(OwnedHandle(handle))
207        }
208    }
209
210    /// Return whether `pid` names a live process. Access denied means the
211    /// process exists but belongs to another user, matching the Unix `EPERM`
212    /// rule.
213    pub(crate) fn process_exists(pid: u32) -> bool {
214        if pid == 0 {
215            return false;
216        }
217        match open_process(pid, PROCESS_QUERY_LIMITED_INFORMATION) {
218            Ok(handle) => {
219                let mut code = 0u32;
220                // SAFETY: `code` outlives the call and the handle is valid.
221                let queried =
222                    unsafe { GetExitCodeProcess(handle.0, std::ptr::from_mut(&mut code)) } != 0;
223                queried && code == STILL_ACTIVE as u32
224            }
225            Err(error) => error.raw_os_error() == Some(ERROR_ACCESS_DENIED.cast_signed()),
226        }
227    }
228
229    /// Forcefully end one process. A process that already exited counts as
230    /// success, like the Unix `ESRCH` rule.
231    pub(crate) fn terminate_process(pid: u32) -> io::Result<()> {
232        let handle = match open_process(pid, PROCESS_TERMINATE) {
233            Ok(handle) => handle,
234            Err(error) if !process_exists(pid) => {
235                let _ = error;
236                return Ok(());
237            }
238            Err(error) => return Err(error),
239        };
240        // SAFETY: valid handle with PROCESS_TERMINATE; integer exit code.
241        if unsafe { TerminateProcess(handle.0, 1) } == 0 {
242            let error = io::Error::last_os_error();
243            if process_exists(pid) {
244                return Err(error);
245            }
246        }
247        Ok(())
248    }
249
250    /// Restrict implicit DLL loading to the application directory and
251    /// System32.
252    ///
253    /// `portable-pty` probes a bare `conpty.dll` through the default search
254    /// order, which includes the current directory and every `PATH` entry, so
255    /// a planted DLL would be loaded into the process that owns the shells.
256    /// After this call, a bare name resolves only beside the executable or in
257    /// System32 and otherwise falls back to the `ConPTY` exports already in
258    /// `kernel32.dll`. Failure is logged and ignored: the process still works,
259    /// just without the narrower search path.
260    pub(crate) fn restrict_dll_search_path() {
261        // SAFETY: the flag is a documented constant; the call has no pointer
262        // arguments and only changes this process's loader policy.
263        if unsafe { SetDefaultDllDirectories(LOAD_LIBRARY_SEARCH_DEFAULT_DIRS) } == 0 {
264            tracing::warn!(
265                error = %io::Error::last_os_error(),
266                "could not restrict the DLL search path"
267            );
268        }
269    }
270
271    /// Owner-only security descriptor for the daemon named pipes.
272    ///
273    /// The default pipe DACL lets other local users open the pipe read-only
274    /// and lets lower-integrity processes of the same user connect, which
275    /// would hand a sandboxed process a shell. This descriptor grants full
276    /// access to SYSTEM and read/write to the current account SID only, and a
277    /// mandatory integrity label refuses readers and writers below the
278    /// daemon's own level (`High` when the daemon runs elevated, otherwise
279    /// `Medium`), so an unelevated client cannot drive an elevated daemon.
280    pub(crate) struct PipeSecurity {
281        descriptor: *mut c_void,
282        attributes: SECURITY_ATTRIBUTES,
283    }
284
285    // SAFETY: the descriptor is a LocalAlloc block that is only read after
286    // construction; nothing about it is thread-affine.
287    unsafe impl Send for PipeSecurity {}
288    // SAFETY: see the `Send` impl; shared references only read the block.
289    unsafe impl Sync for PipeSecurity {}
290
291    impl Drop for PipeSecurity {
292        fn drop(&mut self) {
293            // SAFETY: the pointer came from
294            // ConvertStringSecurityDescriptorToSecurityDescriptorW, whose
295            // contract is a single LocalFree.
296            unsafe {
297                LocalFree(self.descriptor);
298            }
299        }
300    }
301
302    impl PipeSecurity {
303        pub(crate) fn owner_only() -> io::Result<Self> {
304            let sddl = owner_only_sddl()?;
305            let descriptor = parse_sddl(&sddl)?;
306            let attributes = SECURITY_ATTRIBUTES {
307                nLength: u32::try_from(std::mem::size_of::<SECURITY_ATTRIBUTES>())
308                    .map_err(|_| io::Error::other("security attributes too large"))?,
309                lpSecurityDescriptor: descriptor,
310                bInheritHandle: 0,
311            };
312            Ok(Self { descriptor, attributes })
313        }
314
315        /// Create one server instance of `name` carrying this descriptor.
316        pub(crate) fn create_pipe_instance(
317            &self,
318            options: &tokio::net::windows::named_pipe::ServerOptions,
319            name: &str,
320        ) -> io::Result<tokio::net::windows::named_pipe::NamedPipeServer> {
321            let attributes: *const SECURITY_ATTRIBUTES = &raw const self.attributes;
322            // SAFETY: the attributes block and its descriptor outlive the
323            // call, and the descriptor was produced by the SDDL parser.
324            unsafe {
325                options.create_with_security_attributes_raw(name, attributes.cast_mut().cast())
326            }
327        }
328    }
329
330    /// Build the SDDL string for [`PipeSecurity::owner_only`].
331    fn owner_only_sddl() -> io::Result<String> {
332        let token = current_process_token()?;
333        let sid = token_user_sid(&token)?;
334        let integrity = if token_is_elevated(&token)? { "HI" } else { "ME" };
335        Ok(format!("D:P(A;;GA;;;SY)(A;;GRGW;;;{sid})S:(ML;;NRNW;;;{integrity})"))
336    }
337
338    fn current_process_token() -> io::Result<OwnedHandle> {
339        let mut token: HANDLE = std::ptr::null_mut();
340        // SAFETY: the pseudo handle from GetCurrentProcess is always valid and
341        // `token` is a live out-pointer; a zero return is checked.
342        if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token) } == 0 {
343            return Err(io::Error::last_os_error());
344        }
345        Ok(OwnedHandle(token))
346    }
347
348    fn token_user_sid(token: &OwnedHandle) -> io::Result<String> {
349        let mut needed = 0u32;
350        // SAFETY: a null buffer with zero length is the documented way to ask
351        // for the required size; the result is deliberately ignored.
352        unsafe {
353            GetTokenInformation(token.0, TokenUser, std::ptr::null_mut(), 0, &raw mut needed);
354        }
355        if needed == 0 {
356            return Err(io::Error::last_os_error());
357        }
358        // `usize` storage keeps TOKEN_USER aligned and its trailing SID alive.
359        let mut buffer = vec![0usize; (needed as usize).div_ceil(std::mem::size_of::<usize>())];
360        // SAFETY: the buffer is at least `needed` bytes and suitably aligned.
361        if unsafe {
362            GetTokenInformation(
363                token.0,
364                TokenUser,
365                buffer.as_mut_ptr().cast(),
366                needed,
367                &raw mut needed,
368            )
369        } == 0
370        {
371            return Err(io::Error::last_os_error());
372        }
373        // SAFETY: GetTokenInformation initialized a TOKEN_USER at the start of
374        // the buffer, and the SID it points to lives inside the same buffer.
375        let user = unsafe { &*buffer.as_ptr().cast::<TOKEN_USER>() };
376        let mut sid_text: *mut u16 = std::ptr::null_mut();
377        // SAFETY: the SID pointer is valid while `buffer` lives; the out
378        // pointer receives a LocalAlloc string freed below.
379        if unsafe { ConvertSidToStringSidW(user.User.Sid, &raw mut sid_text) } == 0 {
380            return Err(io::Error::last_os_error());
381        }
382        let sid = wide_to_string(sid_text);
383        // SAFETY: ConvertSidToStringSidW documents a single LocalFree.
384        unsafe {
385            LocalFree(sid_text.cast());
386        }
387        Ok(sid)
388    }
389
390    fn token_is_elevated(token: &OwnedHandle) -> io::Result<bool> {
391        let mut elevation = TOKEN_ELEVATION { TokenIsElevated: 0 };
392        let mut needed = 0u32;
393        let size = u32::try_from(std::mem::size_of::<TOKEN_ELEVATION>())
394            .map_err(|_| io::Error::other("token elevation block too large"))?;
395        // SAFETY: the out-pointer references a live, correctly sized struct.
396        if unsafe {
397            GetTokenInformation(
398                token.0,
399                TokenElevation,
400                (&raw mut elevation).cast(),
401                size,
402                &raw mut needed,
403            )
404        } == 0
405        {
406            return Err(io::Error::last_os_error());
407        }
408        Ok(elevation.TokenIsElevated != 0)
409    }
410
411    /// Parse an SDDL string into a self-relative security descriptor that the
412    /// caller must release with `LocalFree`.
413    fn parse_sddl(sddl: &str) -> io::Result<*mut c_void> {
414        let wide: Vec<u16> = sddl.encode_utf16().chain(std::iter::once(0)).collect();
415        let mut descriptor: *mut c_void = std::ptr::null_mut();
416        // SAFETY: `wide` is NUL-terminated and outlives the call; the out
417        // pointer receives a LocalAlloc block; a zero return is checked.
418        if unsafe {
419            ConvertStringSecurityDescriptorToSecurityDescriptorW(
420                wide.as_ptr(),
421                SDDL_REVISION_1,
422                &raw mut descriptor,
423                std::ptr::null_mut(),
424            )
425        } == 0
426        {
427            return Err(io::Error::last_os_error());
428        }
429        Ok(descriptor)
430    }
431
432    fn wide_to_string(text: *const u16) -> String {
433        let mut len = 0usize;
434        // SAFETY: the string is NUL-terminated by its producer; the loop stops
435        // at the terminator without reading past it.
436        while unsafe { *text.add(len) } != 0 {
437            len += 1;
438        }
439        // SAFETY: `len` characters were just read successfully.
440        String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(text, len) })
441    }
442
443    /// Stop this process's standard handles from being inherited by children.
444    ///
445    /// `std::process::Command` calls `CreateProcessW` with `bInheritHandles`
446    /// set, so every inheritable handle leaks into the child even when its own
447    /// stdio is `Stdio::null()`. A detached daemon that inherits the pipes an
448    /// MCP client or a shell gave this process keeps their write ends open,
449    /// and the parent waiting for EOF hangs until the daemon exits. Children
450    /// that should inherit stdio still do: `Stdio::inherit` re-duplicates the
451    /// handle as inheritable at spawn time.
452    pub(crate) fn disable_std_handle_inheritance() {
453        for id in [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] {
454            // SAFETY: GetStdHandle takes a constant and returns a handle or a
455            // sentinel; the sentinels are filtered before the handle is used.
456            let handle = unsafe { GetStdHandle(id) };
457            if handle.is_null() || handle == INVALID_HANDLE_VALUE {
458                continue;
459            }
460            // SAFETY: the handle is one this process owns; clearing a flag on
461            // it is not destructive, and a failure is deliberately ignored
462            // (a console handle without the flag reports an error).
463            unsafe {
464                SetHandleInformation(handle, HANDLE_FLAG_INHERIT, 0);
465            }
466        }
467    }
468
469    /// Detach a child from this console, process group, and, when the job
470    /// permits it, from the job object that may kill this process's children
471    /// (MCP clients commonly wrap servers in such a job). Also stops the
472    /// child from inheriting this process's stdio pipes.
473    pub(crate) fn configure_detached(command: &mut std::process::Command, breakaway: bool) {
474        use std::os::windows::process::CommandExt as _;
475        disable_std_handle_inheritance();
476        let mut flags = DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW;
477        if breakaway {
478            flags |= CREATE_BREAKAWAY_FROM_JOB;
479        }
480        command.creation_flags(flags);
481    }
482
483    /// Job object that kills every assigned process, and their descendants,
484    /// when the last handle closes.
485    pub(crate) struct ProcessTreeJob(OwnedHandle);
486
487    impl ProcessTreeJob {
488        pub(crate) fn new() -> io::Result<Self> {
489            // SAFETY: null attributes and name request an anonymous job with
490            // default security; a null result is checked before use.
491            let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
492            if job.is_null() {
493                return Err(io::Error::last_os_error());
494            }
495            let job = OwnedHandle(job);
496            // SAFETY: a zeroed limit block is a valid all-defaults value for
497            // this POD struct; only the kill-on-close flag is set.
498            let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() };
499            limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
500            let size = u32::try_from(std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>())
501                .map_err(|_| io::Error::other("job limit block too large"))?;
502            // SAFETY: the pointer references a live, correctly sized struct.
503            let configured = unsafe {
504                SetInformationJobObject(
505                    job.0,
506                    JobObjectExtendedLimitInformation,
507                    std::ptr::from_ref(&limits).cast::<c_void>(),
508                    size,
509                )
510            };
511            if configured == 0 {
512                return Err(io::Error::last_os_error());
513            }
514            Ok(Self(job))
515        }
516
517        pub(crate) fn assign(&self, pid: u32) -> io::Result<()> {
518            let process = open_process(pid, PROCESS_SET_QUOTA | PROCESS_TERMINATE)?;
519            // SAFETY: both handles are valid for the duration of the call.
520            if unsafe { AssignProcessToJobObject(self.0 .0, process.0) } == 0 {
521                return Err(io::Error::last_os_error());
522            }
523            Ok(())
524        }
525
526        pub(crate) fn terminate(&self) {
527            // SAFETY: valid job handle; integer exit code.
528            unsafe {
529                TerminateJobObject(self.0 .0, 1);
530            }
531        }
532    }
533
534    #[cfg(test)]
535    mod tests {
536        #![allow(clippy::expect_used)]
537
538        #[test]
539        fn current_process_exists_and_pid_zero_does_not() {
540            assert!(super::process_exists(std::process::id()));
541            assert!(!super::process_exists(0));
542        }
543
544        #[test]
545        fn job_object_can_be_created_and_owns_a_child() {
546            let job = super::ProcessTreeJob::new().expect("job object");
547            let mut child = std::process::Command::new("cmd.exe")
548                .args(["/c", "ping -n 30 127.0.0.1 >nul"])
549                .spawn()
550                .expect("spawn child");
551            job.assign(child.id()).expect("assign child to job");
552            job.terminate();
553            let status = child.wait().expect("child reaped after job termination");
554            assert!(!status.success());
555            assert!(!super::process_exists(child.id()));
556        }
557
558        #[test]
559        fn owner_only_sddl_names_the_current_user_and_an_integrity_label() {
560            let sddl = super::owner_only_sddl().expect("sddl");
561            assert!(sddl.starts_with("D:P(A;;GA;;;SY)(A;;GRGW;;;S-1-"), "{sddl}");
562            assert!(
563                sddl.ends_with("S:(ML;;NRNW;;;ME)") || sddl.ends_with("S:(ML;;NRNW;;;HI)"),
564                "{sddl}"
565            );
566            let security = super::PipeSecurity::owner_only().expect("descriptor");
567            assert!(!security.descriptor.is_null());
568        }
569
570        #[test]
571        fn dll_search_path_restriction_is_accepted() {
572            super::restrict_dll_search_path();
573        }
574
575        #[test]
576        fn std_handles_stop_being_inheritable() {
577            use windows_sys::Win32::Foundation::{
578                GetHandleInformation, HANDLE_FLAG_INHERIT, INVALID_HANDLE_VALUE,
579            };
580            use windows_sys::Win32::System::Console::{
581                GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE,
582            };
583
584            super::disable_std_handle_inheritance();
585            for id in [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] {
586                // SAFETY: constant argument; sentinels are filtered below.
587                let handle = unsafe { GetStdHandle(id) };
588                if handle.is_null() || handle == INVALID_HANDLE_VALUE {
589                    continue;
590                }
591                let mut flags = 0u32;
592                // SAFETY: valid handle and a live out-pointer for the flags.
593                let queried =
594                    unsafe { GetHandleInformation(handle, std::ptr::from_mut(&mut flags)) };
595                if queried == 0 {
596                    // Console handles on some hosts reject the query; nothing to assert.
597                    continue;
598                }
599                assert_eq!(flags & HANDLE_FLAG_INHERIT, 0, "std handle {id} still inheritable");
600            }
601        }
602    }
603}