Skip to main content

kranz_engine/
sandbox_windows.rs

1//! Windows native-containment capability probe and AppContainer/LPAC receipts.
2//!
3//! Microsoft's `Experimental_CreateProcessInSandbox` contract is experimental
4//! and its required `SandboxSpec.fbs` schema is not publicly available.
5//! Guessing that security-critical wire format would turn API presence into a
6//! false safety claim. The probe therefore records only host/API capability.
7//!
8//! The Windows-only tests exercise both the regular-AppContainer primitive
9//! fixture and the stable production LPAC launcher against disposable
10//! directories. The production path is integrated with cleared environment
11//! construction, authority masks, bounded output, validator read denial, gate
12//! wrapping, and Job Object supervision.
13//!
14//! DLL discovery follows Microsoft's documented pattern exactly: load
15//! `processmodel.dll` from System32 only, then resolve the experimental export
16//! dynamically. The restricted search scope prevents a worker-controlled DLL
17//! on the current directory or `PATH` from spoofing capability evidence.
18
19use serde::Serialize;
20use std::path::Path;
21
22pub const PROCESS_MODEL_DLL: &str = "processmodel.dll";
23pub const PROCESS_SANDBOX_EXPORT: &str = "Experimental_CreateProcessInSandbox";
24pub const EXPERIMENTAL_SPEC_VERSION: &str = "0.1.0";
25pub const DLL_SEARCH_SCOPE: &str = "system32-only";
26
27/// Kernel version observed without the manifest-sensitive `GetVersionEx`
28/// compatibility behavior.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
30#[serde(rename_all = "camelCase")]
31pub struct WindowsVersion {
32    pub major: u32,
33    pub minor: u32,
34    pub build: u32,
35}
36
37impl std::fmt::Display for WindowsVersion {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        write!(f, "{}.{}.{}", self.major, self.minor, self.build)
40    }
41}
42
43/// What the host proved about Microsoft's experimental process-sandbox API.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
45#[serde(rename_all = "kebab-case")]
46pub enum ExperimentalApiStatus {
47    NotWindows,
48    DllUnavailable,
49    ExportUnavailable,
50    ExperimentalApiAvailable,
51}
52
53impl ExperimentalApiStatus {
54    pub fn as_str(self) -> &'static str {
55        match self {
56            Self::NotWindows => "not-windows",
57            Self::DllUnavailable => "dll-unavailable",
58            Self::ExportUnavailable => "export-unavailable",
59            Self::ExperimentalApiAvailable => "experimental-api-available",
60        }
61    }
62}
63
64/// Stable, secret-free report suitable for CLI JSON and CI evidence.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
66#[serde(rename_all = "camelCase")]
67pub struct WindowsSandboxProbeReport {
68    pub host_os: &'static str,
69    pub windows_version: Option<WindowsVersion>,
70    pub dll: &'static str,
71    pub export: &'static str,
72    pub dll_search_scope: &'static str,
73    pub experimental_spec_version: &'static str,
74    pub api_status: ExperimentalApiStatus,
75    /// HRESULT from the System32-only DLL load, when that load failed.
76    pub load_error_hresult: Option<i32>,
77    /// True when this build ships a production Windows containment backend.
78    pub production_enabled: bool,
79    pub decision: &'static str,
80}
81
82impl WindowsSandboxProbeReport {
83    pub fn experimental_api_available(&self) -> bool {
84        self.api_status == ExperimentalApiStatus::ExperimentalApiAvailable
85    }
86
87    pub fn render_text(&self) -> String {
88        let version = self
89            .windows_version
90            .map(|version| version.to_string())
91            .unwrap_or_else(|| "unavailable".to_string());
92        let load_error = self
93            .load_error_hresult
94            .map(|code| format!("0x{:08x}", code as u32))
95            .unwrap_or_else(|| "none".to_string());
96        format!(
97            "Windows native containment probe\n\
98             host OS: {}\n\
99             Windows version: {version}\n\
100             DLL: {} ({})\n\
101             export: {}\n\
102             API status: {}\n\
103             load error HRESULT: {load_error}\n\
104             experimental spec: {}\n\
105             production enabled: {}\n\
106             decision: {}\n",
107            self.host_os,
108            self.dll,
109            self.dll_search_scope,
110            self.export,
111            self.api_status.as_str(),
112            self.experimental_spec_version,
113            self.production_enabled,
114            self.decision,
115        )
116    }
117}
118
119/// Probe the current host without creating an AppContainer profile, changing
120/// ACLs, or spawning a child. Absence is a supported result.
121pub fn probe() -> WindowsSandboxProbeReport {
122    platform::probe()
123}
124
125/// Apply and verify the persistent metadata-only AppContainer host ACEs on a
126/// literal local drive root. This mutation is Windows-only and requires an
127/// elevated token; ordinary launch paths never call it.
128#[cfg(windows)]
129pub fn prepare_appcontainer_host(root: &Path) -> std::result::Result<bool, String> {
130    crate::appcontainer_windows::prepare_appcontainer_host(root).map_err(|error| error.to_string())
131}
132
133#[cfg(not(windows))]
134pub fn prepare_appcontainer_host(_root: &Path) -> std::result::Result<bool, String> {
135    Err("AppContainer host preparation is available only on Windows".to_string())
136}
137
138/// Apply the persistent metadata-only ACEs to the profile parent (`C:\Users`),
139/// derived from `USERPROFILE` rather than supplied by the operator. Returns
140/// the prepared path and whether anything changed. Windows tools `lstat` every
141/// ancestor during module resolution, so without this a contained Node gate
142/// fails `EPERM` on `C:\Users` for any repository under a user profile.
143#[cfg(windows)]
144pub fn prepare_appcontainer_profile_parent(
145) -> std::result::Result<(std::path::PathBuf, bool), String> {
146    crate::appcontainer_windows::prepare_appcontainer_profile_parent()
147        .map_err(|error| error.to_string())
148}
149
150#[cfg(not(windows))]
151pub fn prepare_appcontainer_profile_parent(
152) -> std::result::Result<(std::path::PathBuf, bool), String> {
153    Err("AppContainer profile-parent preparation is available only on Windows".to_string())
154}
155
156/// Reapply and verify the documented AppContainer security descriptor on the
157/// Windows null device. The kernel resets it at boot, so host preparation runs
158/// this elevated mutation alongside the persistent drive-root ACEs.
159#[cfg(windows)]
160pub fn prepare_appcontainer_null_device() -> std::result::Result<(), String> {
161    crate::appcontainer_windows::prepare_appcontainer_null_device()
162        .map_err(|error| error.to_string())
163}
164
165#[cfg(not(windows))]
166pub fn prepare_appcontainer_null_device() -> std::result::Result<(), String> {
167    Err("AppContainer null-device preparation is available only on Windows".to_string())
168}
169
170/// Private production-launcher dispatch used by the `kranz` binary before
171/// ordinary CLI initialization. Kept on this public platform module so the
172/// engine's Windows-only unsafe implementation remains crate-private.
173#[cfg(windows)]
174pub fn internal_launcher_requested() -> bool {
175    crate::appcontainer_windows::internal_launcher_requested()
176}
177
178#[cfg(windows)]
179pub fn run_internal_launcher() -> std::result::Result<u32, String> {
180    crate::appcontainer_windows::run_internal_launcher()
181}
182
183#[cfg(windows)]
184pub fn internal_self_test_requested() -> bool {
185    crate::appcontainer_windows::internal_self_test_requested()
186}
187
188#[cfg(windows)]
189pub fn run_production_hostile_self_test() -> std::result::Result<String, String> {
190    crate::appcontainer_windows::run_production_hostile_self_test()
191}
192
193#[cfg(windows)]
194pub fn internal_gate_self_test_requested() -> bool {
195    crate::appcontainer_windows::internal_gate_self_test_requested()
196}
197
198#[cfg(windows)]
199pub fn run_production_gate_self_test() -> std::result::Result<String, String> {
200    crate::appcontainer_windows::run_production_gate_self_test()
201}
202
203#[cfg(windows)]
204pub fn internal_hostile_child_requested() -> bool {
205    crate::appcontainer_windows::internal_hostile_child_requested()
206}
207
208#[cfg(windows)]
209pub fn run_internal_hostile_child() -> std::result::Result<(), String> {
210    crate::appcontainer_windows::run_internal_hostile_child()
211}
212
213#[cfg(windows)]
214mod platform {
215    use super::*;
216    use windows::core::{s, w};
217    use windows::Win32::Foundation::{FreeLibrary, HMODULE};
218    use windows::Win32::System::LibraryLoader::{
219        GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_SYSTEM32,
220    };
221    use windows::Win32::System::SystemInformation::OSVERSIONINFOW;
222
223    #[link(name = "ntdll")]
224    extern "system" {
225        fn RtlGetVersion(version: *mut OSVERSIONINFOW) -> i32;
226    }
227
228    struct Library(HMODULE);
229
230    impl Drop for Library {
231        fn drop(&mut self) {
232            // SAFETY: this guard exclusively owns the module handle returned
233            // by LoadLibraryExW and releases it exactly once.
234            let _ = unsafe { FreeLibrary(self.0) };
235        }
236    }
237
238    fn windows_version() -> Option<WindowsVersion> {
239        let mut version = OSVERSIONINFOW {
240            dwOSVersionInfoSize: std::mem::size_of::<OSVERSIONINFOW>() as u32,
241            ..Default::default()
242        };
243        // SAFETY: RtlGetVersion writes exactly the OSVERSIONINFOW structure
244        // whose initialized size is supplied above. A negative NTSTATUS is a
245        // supported unknown-version result, never a reason to enable anything.
246        let status = unsafe { RtlGetVersion(&mut version) };
247        (status >= 0).then_some(WindowsVersion {
248            major: version.dwMajorVersion,
249            minor: version.dwMinorVersion,
250            build: version.dwBuildNumber,
251        })
252    }
253
254    pub(super) fn probe() -> WindowsSandboxProbeReport {
255        let version = windows_version();
256        // SAFETY: the literal is NUL-terminated and the System32-only flag is
257        // Microsoft's documented loading pattern for this experimental API.
258        let library = match unsafe {
259            LoadLibraryExW(w!("processmodel.dll"), None, LOAD_LIBRARY_SEARCH_SYSTEM32)
260        } {
261            Ok(module) => Library(module),
262            Err(error) => {
263                return WindowsSandboxProbeReport {
264                    host_os: "windows",
265                    windows_version: version,
266                    dll: PROCESS_MODEL_DLL,
267                    export: PROCESS_SANDBOX_EXPORT,
268                    dll_search_scope: DLL_SEARCH_SCOPE,
269                    experimental_spec_version: EXPERIMENTAL_SPEC_VERSION,
270                    api_status: ExperimentalApiStatus::DllUnavailable,
271                    load_error_hresult: Some(error.code().0),
272                    production_enabled: true,
273                    decision: "stable LPAC enforcement is enabled; the experimental System32 DLL is unavailable and unused",
274                };
275            }
276        };
277
278        // SAFETY: `library` is a live System32 module handle and the export
279        // name is a NUL-terminated ASCII literal. We test presence only and
280        // never transmute or call the experimental function pointer.
281        let export =
282            unsafe { GetProcAddress(library.0, s!("Experimental_CreateProcessInSandbox")) };
283        let (api_status, decision) = if export.is_some() {
284            (
285                ExperimentalApiStatus::ExperimentalApiAvailable,
286                "stable LPAC enforcement is enabled; the experimental API is detected but unused",
287            )
288        } else {
289            (
290                ExperimentalApiStatus::ExportUnavailable,
291                "stable LPAC enforcement is enabled; processmodel.dll does not export the unused experimental API",
292            )
293        };
294        WindowsSandboxProbeReport {
295            host_os: "windows",
296            windows_version: version,
297            dll: PROCESS_MODEL_DLL,
298            export: PROCESS_SANDBOX_EXPORT,
299            dll_search_scope: DLL_SEARCH_SCOPE,
300            experimental_spec_version: EXPERIMENTAL_SPEC_VERSION,
301            api_status,
302            load_error_hresult: None,
303            production_enabled: true,
304            decision,
305        }
306    }
307}
308
309#[cfg(not(windows))]
310mod platform {
311    use super::*;
312
313    pub(super) fn probe() -> WindowsSandboxProbeReport {
314        WindowsSandboxProbeReport {
315            host_os: std::env::consts::OS,
316            windows_version: None,
317            dll: PROCESS_MODEL_DLL,
318            export: PROCESS_SANDBOX_EXPORT,
319            dll_search_scope: DLL_SEARCH_SCOPE,
320            experimental_spec_version: EXPERIMENTAL_SPEC_VERSION,
321            api_status: ExperimentalApiStatus::NotWindows,
322            load_error_hresult: None,
323            production_enabled: false,
324            decision:
325                "probe not run: native Windows containment can only be inspected on a Windows host",
326        }
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    #[test]
335    fn probe_reports_the_platform_production_posture_independently_of_experimental_api() {
336        let report = probe();
337        assert_eq!(report.production_enabled, cfg!(windows));
338        assert_eq!(report.dll_search_scope, "system32-only");
339        assert_eq!(report.experimental_spec_version, "0.1.0");
340        assert!(report
341            .render_text()
342            .contains(&format!("production enabled: {}", cfg!(windows))));
343    }
344
345    #[cfg(windows)]
346    #[test]
347    fn windows_experimental_process_sandbox_probe_reports_capability() {
348        let report = probe();
349        println!(
350            "{}",
351            serde_json::to_string(&report).expect("probe report must serialize")
352        );
353        assert_eq!(report.host_os, "windows");
354        assert!(report.windows_version.is_some());
355        assert_ne!(report.api_status, ExperimentalApiStatus::NotWindows);
356        assert!(report.production_enabled);
357        if report.experimental_api_available() {
358            assert_eq!(
359                report.api_status,
360                ExperimentalApiStatus::ExperimentalApiAvailable
361            );
362            assert!(report.load_error_hresult.is_none());
363        }
364    }
365
366    /// M7 Windows containment, phase 3: prove the stable AppContainer token
367    /// and ACL model on a real Windows host without changing the real checkout.
368    ///
369    /// The parent fixture creates a unique profile and four disposable roots,
370    /// then copies this test executable into a read/execute-only toolchain root.
371    /// The child must read that root, write only the worktree and private
372    /// scratch, fail to write a sibling root, and fail to connect to a live
373    /// loopback listener because no network capability is supplied.
374    #[cfg(windows)]
375    #[test]
376    fn windows_appcontainer_hostile_fixture_denies_out_of_root_write_and_network() {
377        appcontainer_fixture::run_parent().expect("AppContainer hostile fixture must pass");
378    }
379
380    /// Re-entered by [`windows_appcontainer_hostile_fixture_denies_out_of_root_write_and_network`]
381    /// inside the AppContainer. An ordinary workspace test run has no manifest
382    /// in its current directory, so the standalone instance is an intentional
383    /// no-op; CI gates the parent test above by its collision-free exact name.
384    #[cfg(windows)]
385    #[test]
386    fn windows_appcontainer_hostile_child() {
387        appcontainer_fixture::run_child_if_requested()
388            .expect("AppContainer hostile child must produce its receipt");
389    }
390
391    #[cfg(windows)]
392    mod appcontainer_fixture {
393        use serde::{Deserialize, Serialize};
394        use std::io;
395        use std::net::{SocketAddr, TcpListener, TcpStream};
396        use std::os::windows::ffi::OsStrExt;
397        use std::path::{Path, PathBuf};
398        use std::ptr::null_mut;
399        use std::time::Duration;
400        use windows::core::{PCWSTR, PWSTR};
401        use windows::Win32::Foundation::{
402            CloseHandle, LocalFree, HANDLE, HLOCAL, WAIT_OBJECT_0, WAIT_TIMEOUT,
403        };
404        use windows::Win32::Security::Authorization::{
405            GetNamedSecurityInfoW, SetEntriesInAclW, SetNamedSecurityInfoW, EXPLICIT_ACCESS_W,
406            GRANT_ACCESS, SE_FILE_OBJECT, TRUSTEE_IS_SID, TRUSTEE_IS_UNKNOWN, TRUSTEE_W,
407        };
408        use windows::Win32::Security::Isolation::{
409            CreateAppContainerProfile, DeleteAppContainerProfile,
410        };
411        use windows::Win32::Security::{
412            FreeSid, GetTokenInformation, TokenIsAppContainer, ACL, CONTAINER_INHERIT_ACE,
413            DACL_SECURITY_INFORMATION, NO_INHERITANCE, OBJECT_INHERIT_ACE, PSECURITY_DESCRIPTOR,
414            PSID, SECURITY_CAPABILITIES, TOKEN_QUERY,
415        };
416        use windows::Win32::Storage::FileSystem::{
417            FILE_GENERIC_EXECUTE, FILE_GENERIC_READ, FILE_GENERIC_WRITE,
418        };
419        use windows::Win32::System::Threading::{
420            CreateProcessW, DeleteProcThreadAttributeList, GetCurrentProcess, GetExitCodeProcess,
421            InitializeProcThreadAttributeList, OpenProcessToken, ResumeThread,
422            UpdateProcThreadAttribute, WaitForSingleObject, CREATE_SUSPENDED,
423            EXTENDED_STARTUPINFO_PRESENT, LPPROC_THREAD_ATTRIBUTE_LIST, PROCESS_INFORMATION,
424            PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, STARTUPINFOEXW,
425        };
426
427        const MANIFEST_NAME: &str = "kranz-appcontainer-fixture.json";
428        const CHILD_TEST: &str = "sandbox_windows::tests::windows_appcontainer_hostile_child";
429        const CHILD_TIMEOUT_MS: u32 = 30_000;
430
431        #[derive(Debug, Serialize, Deserialize)]
432        #[serde(rename_all = "camelCase")]
433        struct FixtureManifest {
434            toolchain_marker: PathBuf,
435            toolchain_denied_write: PathBuf,
436            worktree_write: PathBuf,
437            scratch_write: PathBuf,
438            outside_write: PathBuf,
439            loopback_addr: SocketAddr,
440            receipt: PathBuf,
441        }
442
443        #[derive(Debug, Serialize, Deserialize)]
444        #[serde(rename_all = "camelCase")]
445        struct HostileReceipt {
446            token_is_appcontainer: bool,
447            toolchain_read: bool,
448            toolchain_write_denied: bool,
449            worktree_write: bool,
450            scratch_write: bool,
451            outside_write_denied: bool,
452            network_denied: bool,
453        }
454
455        struct Profile {
456            name: Vec<u16>,
457            sid: PSID,
458            deleted: bool,
459        }
460
461        impl Profile {
462            fn create() -> anyhow::Result<Self> {
463                let name = format!("kranz.phase3.{}", uuid::Uuid::new_v4());
464                let name = wide(&name);
465                let display_name = wide("Kranz phase 3 fixture");
466                let description = wide("Disposable native-containment proof");
467                // SAFETY: all strings are live, NUL-terminated UTF-16 buffers;
468                // zero capabilities is deliberate so network remains denied.
469                let sid = unsafe {
470                    CreateAppContainerProfile(
471                        PCWSTR(name.as_ptr()),
472                        PCWSTR(display_name.as_ptr()),
473                        PCWSTR(description.as_ptr()),
474                        None,
475                    )?
476                };
477                Ok(Self {
478                    name,
479                    sid,
480                    deleted: false,
481                })
482            }
483
484            fn remove(mut self) -> anyhow::Result<()> {
485                // SAFETY: `name` is the same live profile moniker passed to
486                // CreateAppContainerProfile and no child process remains.
487                unsafe { DeleteAppContainerProfile(PCWSTR(self.name.as_ptr()))? };
488                self.deleted = true;
489                Ok(())
490            }
491        }
492
493        impl Drop for Profile {
494            fn drop(&mut self) {
495                if !self.deleted {
496                    // Best-effort unwind cleanup; the success path calls
497                    // `remove` explicitly so CI also proves profile deletion.
498                    let _ = unsafe { DeleteAppContainerProfile(PCWSTR(self.name.as_ptr())) };
499                }
500                // SAFETY: CreateAppContainerProfile returned this SID and the
501                // contract requires exactly one FreeSid call by the owner.
502                unsafe {
503                    FreeSid(self.sid);
504                }
505            }
506        }
507
508        struct LocalAllocation(HLOCAL);
509
510        impl Drop for LocalAllocation {
511            fn drop(&mut self) {
512                // SAFETY: the wrapped pointer came from GetNamedSecurityInfoW
513                // or SetEntriesInAclW and is freed exactly once with LocalFree.
514                unsafe {
515                    LocalFree(Some(self.0));
516                }
517            }
518        }
519
520        struct AttributeList {
521            list: LPPROC_THREAD_ATTRIBUTE_LIST,
522            _storage: Vec<usize>,
523        }
524
525        impl AttributeList {
526            fn security_capabilities(value: &SECURITY_CAPABILITIES) -> anyhow::Result<Self> {
527                let mut bytes = 0usize;
528                // The sizing call intentionally fails with insufficient buffer
529                // while returning the required byte count.
530                let _ = unsafe { InitializeProcThreadAttributeList(None, 1, None, &mut bytes) };
531                anyhow::ensure!(bytes > 0, "attribute-list sizing returned zero bytes");
532                let words = bytes.div_ceil(std::mem::size_of::<usize>());
533                let mut storage = vec![0usize; words];
534                let list = LPPROC_THREAD_ATTRIBUTE_LIST(storage.as_mut_ptr().cast());
535                // SAFETY: the usize allocation is suitably aligned and holds
536                // at least the byte count returned by the sizing call.
537                unsafe { InitializeProcThreadAttributeList(Some(list), 1, None, &mut bytes)? };
538                let result = Self {
539                    list,
540                    _storage: storage,
541                };
542                // SAFETY: `value` remains live through CreateProcessW and its
543                // exact type/size match the documented attribute contract.
544                unsafe {
545                    UpdateProcThreadAttribute(
546                        result.list,
547                        0,
548                        PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES as usize,
549                        Some((value as *const SECURITY_CAPABILITIES).cast()),
550                        std::mem::size_of::<SECURITY_CAPABILITIES>(),
551                        None,
552                        None,
553                    )?
554                };
555                Ok(result)
556            }
557        }
558
559        impl Drop for AttributeList {
560            fn drop(&mut self) {
561                // SAFETY: InitializeProcThreadAttributeList initialized this
562                // allocation and the owner deletes the list exactly once.
563                unsafe { DeleteProcThreadAttributeList(self.list) };
564            }
565        }
566
567        struct ProcessHandles {
568            process: HANDLE,
569            thread: HANDLE,
570        }
571
572        impl Drop for ProcessHandles {
573            fn drop(&mut self) {
574                // SAFETY: CreateProcessW returned both handles; this guard owns
575                // and closes each one exactly once.
576                unsafe {
577                    let _ = CloseHandle(self.thread);
578                    let _ = CloseHandle(self.process);
579                }
580            }
581        }
582
583        struct OwnedHandle(HANDLE);
584
585        impl Drop for OwnedHandle {
586            fn drop(&mut self) {
587                // SAFETY: OpenProcessToken returned this owned token handle.
588                let _ = unsafe { CloseHandle(self.0) };
589            }
590        }
591
592        fn wide(value: impl AsRef<std::ffi::OsStr>) -> Vec<u16> {
593            value.as_ref().encode_wide().chain(Some(0)).collect()
594        }
595
596        fn win32(status: windows::Win32::Foundation::WIN32_ERROR) -> anyhow::Result<()> {
597            status.ok().map_err(anyhow::Error::from)
598        }
599
600        fn grant_path(
601            path: &Path,
602            sid: PSID,
603            permissions: u32,
604            inherit: bool,
605        ) -> anyhow::Result<()> {
606            let path = wide(path.as_os_str());
607            let mut old_acl: *mut ACL = null_mut();
608            let mut security_descriptor = PSECURITY_DESCRIPTOR::default();
609            // SAFETY: the path buffer and output pointers are valid. The
610            // returned security descriptor owns `old_acl` and is LocalFree'd.
611            win32(unsafe {
612                GetNamedSecurityInfoW(
613                    PCWSTR(path.as_ptr()),
614                    SE_FILE_OBJECT,
615                    DACL_SECURITY_INFORMATION,
616                    None,
617                    None,
618                    Some(&mut old_acl),
619                    None,
620                    &mut security_descriptor,
621                )
622            })?;
623            let _security_descriptor = LocalAllocation(HLOCAL(security_descriptor.0));
624
625            let entry = EXPLICIT_ACCESS_W {
626                grfAccessPermissions: permissions,
627                grfAccessMode: GRANT_ACCESS,
628                grfInheritance: if inherit {
629                    OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE
630                } else {
631                    NO_INHERITANCE
632                },
633                Trustee: TRUSTEE_W {
634                    TrusteeForm: TRUSTEE_IS_SID,
635                    TrusteeType: TRUSTEE_IS_UNKNOWN,
636                    ptstrName: PWSTR(sid.0.cast()),
637                    ..Default::default()
638                },
639            };
640            let mut new_acl: *mut ACL = null_mut();
641            // SAFETY: `entry` contains the live profile SID; `old_acl` stays
642            // alive through the owning security descriptor above.
643            win32(unsafe { SetEntriesInAclW(Some(&[entry]), Some(old_acl), &mut new_acl) })?;
644            let _new_acl = LocalAllocation(HLOCAL(new_acl.cast()));
645            // SAFETY: all pointers remain live for this call. This merges one
646            // AppContainer ACE into the existing DACL instead of replacing the
647            // user's access, and only disposable fixture paths are modified.
648            win32(unsafe {
649                SetNamedSecurityInfoW(
650                    PCWSTR(path.as_ptr()),
651                    SE_FILE_OBJECT,
652                    DACL_SECURITY_INFORMATION,
653                    None,
654                    None,
655                    Some(new_acl),
656                    None,
657                )
658            })
659        }
660
661        fn is_appcontainer_process() -> anyhow::Result<bool> {
662            let mut access_handle = HANDLE::default();
663            // SAFETY: GetCurrentProcess is a valid pseudohandle and the output
664            // receives one owned process access-token handle.
665            unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut access_handle)? };
666            let access_handle = OwnedHandle(access_handle);
667            let mut value = 0u32;
668            let mut returned = 0u32;
669            // SAFETY: TokenIsAppContainer returns one u32 into the exact-size
670            // initialized output buffer supplied here.
671            unsafe {
672                GetTokenInformation(
673                    access_handle.0,
674                    TokenIsAppContainer,
675                    Some((&mut value as *mut u32).cast()),
676                    std::mem::size_of::<u32>() as u32,
677                    &mut returned,
678                )?
679            };
680            anyhow::ensure!(returned as usize == std::mem::size_of::<u32>());
681            Ok(value != 0)
682        }
683
684        fn quote_argument(value: &std::ffi::OsStr) -> String {
685            let value = value.to_string_lossy();
686            format!("\"{}\"", value.replace('"', "\\\""))
687        }
688
689        fn launch(executable: &Path, cwd: &Path, sid: PSID) -> anyhow::Result<u32> {
690            let security = SECURITY_CAPABILITIES {
691                AppContainerSid: sid,
692                Capabilities: null_mut(),
693                CapabilityCount: 0,
694                Reserved: 0,
695            };
696            let attributes = AttributeList::security_capabilities(&security)?;
697            let mut startup = STARTUPINFOEXW::default();
698            startup.StartupInfo.cb = std::mem::size_of::<STARTUPINFOEXW>() as u32;
699            startup.lpAttributeList = attributes.list;
700
701            let application = wide(executable.as_os_str());
702            let cwd = wide(cwd.as_os_str());
703            let command_line = format!(
704                "{} {} --exact --nocapture",
705                quote_argument(executable.as_os_str()),
706                quote_argument(std::ffi::OsStr::new(CHILD_TEST)),
707            );
708            let mut command_line = wide(command_line);
709            let mut process_info = PROCESS_INFORMATION::default();
710            // SAFETY: every buffer and structure remains live through the call;
711            // the mutable command line satisfies CreateProcessW's contract.
712            unsafe {
713                CreateProcessW(
714                    PCWSTR(application.as_ptr()),
715                    Some(PWSTR(command_line.as_mut_ptr())),
716                    None,
717                    None,
718                    false,
719                    CREATE_SUSPENDED | EXTENDED_STARTUPINFO_PRESENT,
720                    None,
721                    PCWSTR(cwd.as_ptr()),
722                    &startup.StartupInfo,
723                    &mut process_info,
724                )?
725            };
726            let handles = ProcessHandles {
727                process: process_info.hProcess,
728                thread: process_info.hThread,
729            };
730
731            // Fail closed: assigning the still-suspended process closes the
732            // spawn-before-supervision race. No hostile instruction runs until
733            // the kill-on-close Job Object owns the process tree.
734            let job =
735                crate::backend_claude::win_job::JobHandle::create_and_assign(handles.process.0)?;
736            // SAFETY: `handles.thread` is the suspended primary thread.
737            anyhow::ensure!(unsafe { ResumeThread(handles.thread) } != u32::MAX);
738
739            // SAFETY: the process handle remains live in `handles`.
740            let wait = unsafe { WaitForSingleObject(handles.process, CHILD_TIMEOUT_MS) };
741            if wait == WAIT_TIMEOUT {
742                job.kill();
743                anyhow::bail!("AppContainer fixture timed out after {CHILD_TIMEOUT_MS}ms");
744            }
745            anyhow::ensure!(
746                wait == WAIT_OBJECT_0,
747                "WaitForSingleObject returned {wait:?}"
748            );
749            let mut exit_code = 0u32;
750            // SAFETY: the signaled process handle is valid and exit_code is an
751            // exact initialized output buffer.
752            unsafe { GetExitCodeProcess(handles.process, &mut exit_code)? };
753            Ok(exit_code)
754        }
755
756        pub(super) fn run_parent() -> anyhow::Result<()> {
757            let root = tempfile::tempdir()?;
758            let toolchain = root.path().join("toolchain");
759            let worktree = root.path().join("worktree");
760            let scratch = root.path().join("scratch");
761            let outside = root.path().join("outside");
762            for path in [&toolchain, &worktree, &scratch, &outside] {
763                std::fs::create_dir(path)?;
764            }
765
766            let profile = Profile::create()?;
767            let read_execute = FILE_GENERIC_READ.0 | FILE_GENERIC_EXECUTE.0;
768            let read_write_execute = read_execute | FILE_GENERIC_WRITE.0;
769            // The parent root only needs traversal; non-inheriting read/execute
770            // exposes no child object whose own DACL lacks an AppContainer ACE.
771            grant_path(root.path(), profile.sid, read_execute, false)?;
772            grant_path(&toolchain, profile.sid, read_execute, true)?;
773            grant_path(&worktree, profile.sid, read_write_execute, true)?;
774            grant_path(&scratch, profile.sid, read_write_execute, true)?;
775
776            let current_exe = std::env::current_exe()?;
777            let executable = toolchain.join(
778                current_exe
779                    .file_name()
780                    .ok_or_else(|| anyhow::anyhow!("test executable has no file name"))?,
781            );
782            std::fs::copy(&current_exe, &executable)?;
783            let toolchain_marker = toolchain.join("read-only-marker.txt");
784            std::fs::write(&toolchain_marker, "kranz-appcontainer-phase3")?;
785
786            let listener = TcpListener::bind("127.0.0.1:0")?;
787            let manifest = FixtureManifest {
788                toolchain_marker,
789                toolchain_denied_write: toolchain.join("must-not-write.txt"),
790                worktree_write: worktree.join("allowed-worktree.txt"),
791                scratch_write: scratch.join("allowed-scratch.txt"),
792                outside_write: outside.join("must-not-write.txt"),
793                loopback_addr: listener.local_addr()?,
794                receipt: scratch.join("receipt.json"),
795            };
796            let manifest_path = worktree.join(MANIFEST_NAME);
797            std::fs::write(&manifest_path, serde_json::to_vec_pretty(&manifest)?)?;
798
799            let exit_code = launch(&executable, &worktree, profile.sid)?;
800            anyhow::ensure!(exit_code == 0, "AppContainer child exited {exit_code}");
801            let receipt: HostileReceipt =
802                serde_json::from_slice(&std::fs::read(&manifest.receipt)?)?;
803            println!("{}", serde_json::to_string(&receipt)?);
804            anyhow::ensure!(
805                receipt.token_is_appcontainer,
806                "child token was not AppContainer"
807            );
808            anyhow::ensure!(receipt.toolchain_read, "read-only toolchain was unreadable");
809            anyhow::ensure!(receipt.toolchain_write_denied, "toolchain write escaped");
810            anyhow::ensure!(receipt.worktree_write, "worktree write was denied");
811            anyhow::ensure!(receipt.scratch_write, "private scratch write was denied");
812            anyhow::ensure!(receipt.outside_write_denied, "out-of-root write escaped");
813            anyhow::ensure!(receipt.network_denied, "network access escaped");
814            anyhow::ensure!(!manifest.toolchain_denied_write.exists());
815            anyhow::ensure!(!manifest.outside_write.exists());
816            profile.remove()?;
817            Ok(())
818        }
819
820        pub(super) fn run_child_if_requested() -> anyhow::Result<()> {
821            let manifest_path = std::env::current_dir()?.join(MANIFEST_NAME);
822            if !manifest_path.is_file() {
823                return Ok(());
824            }
825            let manifest: FixtureManifest =
826                serde_json::from_slice(&std::fs::read(&manifest_path)?)?;
827            let toolchain_read = std::fs::read_to_string(&manifest.toolchain_marker)
828                .map(|value| value == "kranz-appcontainer-phase3")
829                .unwrap_or(false);
830            let toolchain_write_denied =
831                std::fs::write(&manifest.toolchain_denied_write, "escape").is_err();
832            let worktree_write = std::fs::write(&manifest.worktree_write, "allowed").is_ok();
833            let scratch_write = std::fs::write(&manifest.scratch_write, "allowed").is_ok();
834            let outside_write_denied = std::fs::write(&manifest.outside_write, "escape").is_err();
835            let network_denied =
836                TcpStream::connect_timeout(&manifest.loopback_addr, Duration::from_secs(2))
837                    .is_err();
838            let receipt = HostileReceipt {
839                token_is_appcontainer: is_appcontainer_process()?,
840                toolchain_read,
841                toolchain_write_denied,
842                worktree_write,
843                scratch_write,
844                outside_write_denied,
845                network_denied,
846            };
847            std::fs::write(&manifest.receipt, serde_json::to_vec_pretty(&receipt)?)
848                .map_err(|error| io::Error::new(error.kind(), format!("write receipt: {error}")))?;
849            Ok(())
850        }
851    }
852}