diff --git a/src/backends/appcontainer/common/src/base_container_runner.rs b/src/backends/appcontainer/common/src/base_container_runner.rs index 137d7f6ea..8f3d0ecb7 100644 --- a/src/backends/appcontainer/common/src/base_container_runner.rs +++ b/src/backends/appcontainer/common/src/base_container_runner.rs @@ -606,6 +606,20 @@ impl BaseContainerRunner { }) } + /// Whether the native PSEC plus Learning Mode API set is available. + /// + /// This does not start a Learning Mode trace. It also does not evaluate + /// whether a particular request is compatible with PSEC. + pub fn is_native_capture_available() -> bool { + #[cfg(test)] + if let Ok(forced) = std::env::var("MXC_FORCE_NATIVE_CAPTURE_USABLE") { + return forced == "1"; + } + + Self::is_process_security_environment_usable() + && RealCapturePlatformSupport.check_apis(true).is_ok() + } + /// Whether this host can create a PSEC environment and start a Learning Mode trace. /// /// The successful probe session is dropped immediately, which closes and discards the trace. diff --git a/src/backends/appcontainer/common/src/probe.rs b/src/backends/appcontainer/common/src/probe.rs index da1fc14ee..a7dee50c8 100644 --- a/src/backends/appcontainer/common/src/probe.rs +++ b/src/backends/appcontainer/common/src/probe.rs @@ -43,6 +43,15 @@ pub struct ProbeOutput { pub struct ProbeFacts { /// `Experimental_CreateProcessInSandbox` is resolvable. pub base_container_api_present: bool, + /// Whether the preferred native PSEC plus Learning Mode capture path is usable. + /// + /// A false value does not mean `captureDenials` is unsupported: the executor + /// can use guarded WPR on a compatible fallback tier. A true value reports + /// host capability, not request compatibility; a request can still require + /// guarded WPR when its policy cannot be represented by PSEC. + pub native_capture_available: bool, + /// Whether the guarded WPR capture fallback is available. + pub guarded_capture_available: bool, /// `bfscfg.exe` is on disk in `%SystemRoot%\System32`. /// /// Always `false` when [`Self::bfs_compiled_in`] is `false`, because @@ -119,31 +128,64 @@ impl From for UiCapabilitySupport { } } -/// Probe backend support for `request`. -pub fn run_probe(request: &ExecutionRequest) -> ProbeOutput { +/// Run the probe with the availability of the guarded WPR capture fallback. +/// +/// The concrete guarded-capture implementation lives above this crate in +/// `mxc_engine`, which supplies this capability to the executor probe. +pub fn run_probe(request: &ExecutionRequest, guarded_capture_available: bool) -> ProbeOutput { + use crate::base_container_runner::BaseContainerRunner; + + let base_container_usable = BaseContainerRunner::is_usable_for_request(request); + let uses_native_capture = BaseContainerRunner::uses_native_capture_for_request(request); + let supports_deny_paths = BaseContainerRunner::supports_deny_paths_for_request(request); let probes = ProbeFacts { - base_container_api_present: - crate::base_container_runner::BaseContainerRunner::is_base_container_api_present() - .is_ok(), + base_container_api_present: BaseContainerRunner::is_base_container_api_present().is_ok(), + native_capture_available: BaseContainerRunner::is_native_capture_available(), + guarded_capture_available, bfscfg_present: fallback_detector::find_bfscfg_exe() .ok() .flatten() .is_some(), bfs_compiled_in: cfg!(feature = "tier2_bfs"), - base_container_supports_deny_paths: - crate::base_container_runner::BaseContainerRunner::supports_native_denied_paths(), + base_container_supports_deny_paths: BaseContainerRunner::supports_native_denied_paths(), base_container_supports_ingress_host_loopback_allow: - crate::base_container_runner::BaseContainerRunner::supports_ingress_host_loopback_allow( - ), + BaseContainerRunner::supports_ingress_host_loopback_allow(), isolation_session_available: false, hyperlight_available: false, ui_capabilities: crate::job_object::supported_ui_restrictions().into(), }; - let base_container_usable = - crate::base_container_runner::BaseContainerRunner::is_usable_for_request(request); - let supports_deny_paths = - crate::base_container_runner::BaseContainerRunner::supports_deny_paths_for_request(request); + + run_probe_with_capabilities( + request, + probes, + base_container_usable, + uses_native_capture, + supports_deny_paths, + ) +} + +fn run_probe_with_capabilities( + request: &ExecutionRequest, + probes: ProbeFacts, + base_container_usable: bool, + uses_native_capture: bool, + supports_deny_paths: bool, +) -> ProbeOutput { match detect_request_tier(request, base_container_usable, supports_deny_paths) { + Ok(decision) + if request.policy.capture_denials.is_some() + && (decision.tier != fallback_detector::IsolationTier::BaseContainer + || !uses_native_capture) + && !probes.guarded_capture_available => + { + ProbeOutput { + tier: None, + needs_dacl_augmentation: None, + warnings: decision.warnings, + probes, + error: Some("guarded WPR captureDenials fallback is unavailable".to_string()), + } + } Ok(decision) => ProbeOutput { tier: Some(decision.tier.as_str()), needs_dacl_augmentation: Some(decision.needs_dacl_augmentation), @@ -201,7 +243,8 @@ pub fn to_json_pretty(output: &ProbeOutput) -> Result mod tests { use super::*; use crate::fallback_detector::IsolationTier; - use crate::test_env::ForceTierGuard; + use crate::test_env::{CaptureCapabilityGuard, ForceTierGuard}; + use wxc_common::models::{ContainerPolicy, ExecutionRequest}; fn all_ui_capabilities() -> UiCapabilitySupport { UiCapabilitySupport { @@ -218,6 +261,31 @@ mod tests { } } + fn request_with_policy(policy: ContainerPolicy) -> ExecutionRequest { + ExecutionRequest { + policy, + ..Default::default() + } + } + + fn test_probe_facts( + native_capture_available: bool, + guarded_capture_available: bool, + ) -> ProbeFacts { + ProbeFacts { + base_container_api_present: true, + native_capture_available, + guarded_capture_available, + bfscfg_present: false, + bfs_compiled_in: false, + base_container_supports_deny_paths: false, + base_container_supports_ingress_host_loopback_allow: false, + isolation_session_available: false, + hyperlight_available: false, + ui_capabilities: all_ui_capabilities(), + } + } + #[test] fn probe_output_serializes() { let out = ProbeOutput { @@ -226,6 +294,8 @@ mod tests { warnings: vec!["a warning".to_string()], probes: ProbeFacts { base_container_api_present: true, + native_capture_available: true, + guarded_capture_available: true, bfscfg_present: false, bfs_compiled_in: false, base_container_supports_deny_paths: false, @@ -242,6 +312,8 @@ mod tests { assert_eq!(v["needsDaclAugmentation"], false); assert_eq!(v["warnings"][0], "a warning"); assert_eq!(v["probes"]["baseContainerApiPresent"], true); + assert_eq!(v["probes"]["nativeCaptureAvailable"], true); + assert_eq!(v["probes"]["guardedCaptureAvailable"], true); assert_eq!(v["probes"]["bfscfgPresent"], false); assert_eq!(v["probes"]["bfsCompiledIn"], false); assert_eq!(v["probes"]["baseContainerSupportsDenyPaths"], false); @@ -270,6 +342,8 @@ mod tests { warnings: vec![], probes: ProbeFacts { base_container_api_present: false, + native_capture_available: false, + guarded_capture_available: false, bfscfg_present: false, bfs_compiled_in: false, base_container_supports_deny_paths: false, @@ -298,6 +372,156 @@ mod tests { assert_eq!(v["probes"]["uiCapabilities"]["canBlockClipboardRead"], true); } + #[test] + fn request_capabilities_control_base_container_selection() { + let _lock = crate::test_env::lock(); + let request = ExecutionRequest::default(); + let probes = test_probe_facts(false, false); + + let selected = run_probe_with_capabilities(&request, probes, true, false, true); + + assert_eq!(selected.tier, Some("base-container")); + assert!(selected.error.is_none()); + } + + #[test] + fn capture_denials_remains_launchable_on_appcontainer_fallback() { + let _guard = ForceTierGuard::set_tier(IsolationTier::AppContainerDacl); + let policy = ContainerPolicy { + capture_denials: Some(Default::default()), + ..Default::default() + }; + let request = request_with_policy(policy); + + let output = run_probe_with_capabilities( + &request, + test_probe_facts(false, true), + false, + false, + false, + ); + + assert_eq!(output.tier, Some("appcontainer-dacl")); + assert!(!output.probes.native_capture_available); + assert!(output.error.is_none()); + } + + #[test] + fn capture_denials_requires_guarded_capture_on_appcontainer_fallback() { + let _guard = ForceTierGuard::set_tier(IsolationTier::AppContainerDacl); + let policy = ContainerPolicy { + capture_denials: Some(Default::default()), + ..Default::default() + }; + let output = run_probe_with_capabilities( + &request_with_policy(policy), + test_probe_facts(false, false), + false, + false, + false, + ); + + assert!(output.tier.is_none()); + assert!(output.needs_dacl_augmentation.is_none()); + assert!(!output.probes.guarded_capture_available); + assert_eq!( + output.error.as_deref(), + Some("guarded WPR captureDenials fallback is unavailable") + ); + } + + #[test] + fn capture_denials_requires_guarded_capture_on_legacy_base_container() { + let _guard = ForceTierGuard::set_tier(IsolationTier::BaseContainer); + let policy = ContainerPolicy { + capture_denials: Some(Default::default()), + ..Default::default() + }; + let output = run_probe_with_capabilities( + &request_with_policy(policy), + test_probe_facts(false, false), + true, + false, + true, + ); + + assert!(output.tier.is_none()); + assert!(output.needs_dacl_augmentation.is_none()); + assert_eq!( + output.error.as_deref(), + Some("guarded WPR captureDenials fallback is unavailable") + ); + } + + #[test] + fn native_capture_does_not_require_guarded_capture_on_base_container() { + let _guard = ForceTierGuard::set_tier(IsolationTier::BaseContainer); + let policy = ContainerPolicy { + capture_denials: Some(Default::default()), + ..Default::default() + }; + let output = run_probe_with_capabilities( + &request_with_policy(policy), + test_probe_facts(true, false), + true, + true, + true, + ); + + assert_eq!(output.tier, Some("base-container")); + assert!(!output.probes.guarded_capture_available); + assert!(output.error.is_none()); + } + + #[test] + fn public_probe_reports_native_capture_and_selects_base_container() { + let _guard = CaptureCapabilityGuard::set(true, true); + let policy = ContainerPolicy { + capture_denials: Some(Default::default()), + ..Default::default() + }; + + let output = run_probe(&request_with_policy(policy), true); + + assert_eq!(output.tier, Some("base-container")); + assert!(output.probes.native_capture_available); + assert!(output.error.is_none()); + } + + #[test] + fn public_probe_keeps_capture_launchable_without_native_capture() { + let _guard = CaptureCapabilityGuard::set(false, false); + let policy = ContainerPolicy { + capture_denials: Some(Default::default()), + ..Default::default() + }; + + let output = run_probe(&request_with_policy(policy), true); + + assert_ne!(output.tier, Some("base-container")); + assert!(!output.probes.native_capture_available); + assert!(output.error.is_none()); + } + + #[test] + fn public_probe_rejects_unavailable_guarded_capture() { + let _guard = CaptureCapabilityGuard::set(false, false); + let policy = ContainerPolicy { + capture_denials: Some(Default::default()), + ..Default::default() + }; + + let output = run_probe(&request_with_policy(policy), false); + + assert!(output.tier.is_none()); + assert!(!output.probes.native_capture_available); + assert!(!output.probes.guarded_capture_available); + assert_eq!( + output.error.as_deref(), + Some("guarded WPR captureDenials fallback is unavailable") + ); + } + #[test] fn tier_strings_stable() { assert_eq!(IsolationTier::BaseContainer.as_str(), "base-container"); @@ -312,7 +536,7 @@ mod tests { fn run_probe_with_force_tier() { let _g = ForceTierGuard::set_tier(IsolationTier::AppContainerBfs); let request = ExecutionRequest::default(); - let out = run_probe(&request); + let out = run_probe(&request, true); assert_eq!(out.tier, Some("appcontainer-bfs")); assert_eq!(out.needs_dacl_augmentation, Some(false)); assert!(out.error.is_none()); @@ -321,9 +545,9 @@ mod tests { #[test] fn run_probe_handles_dacl_disabled_error() { let _g = ForceTierGuard::set_tier(IsolationTier::AppContainerDacl); - let mut request = ExecutionRequest::default(); - request.policy.fallback.allow_dacl_mutation = false; - let out = run_probe(&request); + let mut policy = ContainerPolicy::default(); + policy.fallback.allow_dacl_mutation = false; + let out = run_probe(&request_with_policy(policy), true); assert!(out.tier.is_none()); assert!(out.needs_dacl_augmentation.is_none()); assert!(out.error.is_some()); @@ -337,9 +561,9 @@ mod tests { #[test] fn omitted_fields_when_error() { let _g = ForceTierGuard::set_tier(IsolationTier::AppContainerDacl); - let mut request = ExecutionRequest::default(); - request.policy.fallback.allow_dacl_mutation = false; - let out = run_probe(&request); + let mut policy = ContainerPolicy::default(); + policy.fallback.allow_dacl_mutation = false; + let out = run_probe(&request_with_policy(policy), true); let v = serde_json::to_value(&out).expect("to_value"); let obj = v.as_object().expect("object"); assert!( @@ -359,7 +583,7 @@ mod tests { fn probe_always_emits_isolation_session_available() { // The SDK's isolation-session gate reads this non-optional field, so // it must always serialize (never omitted), even when false. - let out = run_probe(&ExecutionRequest::default()); + let out = run_probe(&ExecutionRequest::default(), true); let v = serde_json::to_value(&out).expect("to_value"); let probes = v["probes"].as_object().expect("probes object"); assert!( @@ -370,6 +594,7 @@ mod tests { #[test] fn request_detector_keeps_supported_denied_paths_on_base_container() { + let _guard = crate::test_env::lock(); let mut request = ExecutionRequest::default(); request.policy.denied_paths = vec!["C:\\secret".to_string()]; diff --git a/src/core/mxc_engine/src/guarded_capture.rs b/src/core/mxc_engine/src/guarded_capture.rs index 25b950bb7..46edf4751 100644 --- a/src/core/mxc_engine/src/guarded_capture.rs +++ b/src/core/mxc_engine/src/guarded_capture.rs @@ -88,6 +88,28 @@ fn plm_exe_path() -> Result { Ok(dir.join("plm.exe")) } +/// Whether the guarded WPR fallback can be used without starting a capture. +/// +/// This verifies that the system WPR executable is present and applies the same +/// guardian co-location and trust checks as launch, then releases the pin +/// immediately. It intentionally does not attempt to elevate or start WPR +/// because callers use it from the read-only host probe. +pub(crate) fn is_available() -> bool { + prerequisites_available(plm::wpr_path::verify_wpr_present, || { + let path = plm_exe_path()?; + plm::trust::verify_and_pin_launch_binary(&path) + .map(|_| ()) + .map_err(|error| error.to_string()) + }) +} + +fn prerequisites_available( + verify_wpr: impl FnOnce() -> Result<(), String>, + verify_guardian: impl FnOnce() -> Result<(), String>, +) -> bool { + verify_wpr().and_then(|()| verify_guardian()).is_ok() +} + /// [`GuardedCaptureSession`] backed by a live `plm::elevated::GuardedSession`. struct PlmGuardedCaptureSession { session: plm::elevated::GuardedSession, @@ -236,6 +258,31 @@ pub(crate) fn factory_for_request( mod tests { use super::*; + #[test] + fn guarded_capture_is_unavailable_without_wpr() { + let mut guardian_checked = false; + + let available = prerequisites_available( + || Err("wpr.exe is missing".to_string()), + || { + guardian_checked = true; + Ok(()) + }, + ); + + assert!(!available); + assert!(!guardian_checked); + } + + #[test] + fn guarded_capture_requires_both_prerequisites() { + assert!(!prerequisites_available( + || Ok(()), + || Err("plm.exe is unavailable".to_string()) + )); + assert!(prerequisites_available(|| Ok(()), || Ok(()))); + } + #[test] fn start_rejects_a_nonexistent_plm_path() { // Deterministic regardless of build/CI environment: point at a path diff --git a/src/core/mxc_engine/src/lib.rs b/src/core/mxc_engine/src/lib.rs index 2f74b897b..a082a8f47 100644 --- a/src/core/mxc_engine/src/lib.rs +++ b/src/core/mxc_engine/src/lib.rs @@ -55,6 +55,10 @@ pub use policy::{ }; pub use probe::{available_backends, to_json_pretty, AvailableBackend, BackendCapability}; #[cfg(target_os = "windows")] +pub fn guarded_capture_available() -> bool { + guarded_capture::is_available() +} +#[cfg(target_os = "windows")] pub use run::resolve_runner_for_audit; #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] pub use run::{log_policy_hash, resolve_runner, run, ResolvedRunner}; diff --git a/src/core/mxc_engine/src/probe.rs b/src/core/mxc_engine/src/probe.rs index bd9991414..24a892875 100644 --- a/src/core/mxc_engine/src/probe.rs +++ b/src/core/mxc_engine/src/probe.rs @@ -12,6 +12,9 @@ use serde::Serialize; use wxc_common::models::ContainmentBackend; +#[cfg(target_os = "windows")] +use crate::guarded_capture; + /// Optional feature supported by a containment backend on the current host. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] #[non_exhaustive] @@ -91,7 +94,10 @@ pub fn available_backends() -> Vec { windows_backends( tier, ProcessContainerCapabilities { - capture_denials: appcontainer_common::base_container_runner::BaseContainerRunner::is_capture_denials_usable(), + capture_denials: capture_denials_available( + appcontainer_common::base_container_runner::BaseContainerRunner::is_capture_denials_usable(), + guarded_capture::is_available(), + ), filesystem_denied_paths: appcontainer_common::base_container_runner::BaseContainerRunner::supports_native_denied_paths(), ingress_host_loopback_allow: appcontainer_common::base_container_runner::BaseContainerRunner::supports_ingress_host_loopback_allow(), }, @@ -160,6 +166,11 @@ struct ProcessContainerCapabilities { ingress_host_loopback_allow: bool, } +#[cfg(target_os = "windows")] +fn capture_denials_available(native_capture: bool, guarded_capture: bool) -> bool { + native_capture || guarded_capture +} + #[cfg(target_os = "windows")] fn windows_backends( tier: appcontainer_common::fallback_detector::IsolationTier, @@ -454,14 +465,27 @@ mod tests { #[cfg(target_os = "windows")] #[test] - fn windows_reports_capture_denials_from_probe_result() { + fn capture_denials_is_available_with_either_provider() { + for (native, guarded, expected) in [ + (false, false, false), + (true, false, true), + (false, true, true), + (true, true, true), + ] { + assert_eq!(capture_denials_available(native, guarded), expected); + } + } + + #[cfg(target_os = "windows")] + #[test] + fn windows_reports_capture_denials_from_combined_provider_result() { use appcontainer_common::fallback_detector::IsolationTier; - for capture_denials_usable in [false, true] { + for capture_denials_available in [false, true] { let backends = windows_backends( IsolationTier::BaseContainer, ProcessContainerCapabilities { - capture_denials: capture_denials_usable, + capture_denials: capture_denials_available, ..Default::default() }, ); @@ -473,7 +497,7 @@ mod tests { process_container .capabilities .contains(&BackendCapability::CaptureDenials), - capture_denials_usable + capture_denials_available ); } } diff --git a/src/core/wxc/src/main.rs b/src/core/wxc/src/main.rs index 2e7b746da..688f6a636 100644 --- a/src/core/wxc/src/main.rs +++ b/src/core/wxc/src/main.rs @@ -996,7 +996,10 @@ fn main() { } else { wxc_common::models::ExecutionRequest::default() }; - let output = appcontainer_common::probe::run_probe(&request); + let output = appcontainer_common::probe::run_probe( + &request, + mxc_engine::guarded_capture_available(), + ); // appcontainer_common has no dependency on the isolation-session // backend, so it reports `isolationSessionAvailable` as `false`. When // the backend is compiled in, override it with a read-only activation