Skip to content
14 changes: 14 additions & 0 deletions src/backends/appcontainer/common/src/base_container_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
269 changes: 247 additions & 22 deletions src/backends/appcontainer/common/src/probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -119,31 +128,64 @@ impl From<EffectiveUiRestrictions> 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)
Comment on lines +177 to +178

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: Would probably be best if we see if there was something we can do to combine these checks or consolidate them to they're easy to reason about at a glance.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noted

&& !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),
Expand Down Expand Up @@ -201,7 +243,8 @@ pub fn to_json_pretty(output: &ProbeOutput) -> Result<String, serde_json::Error>
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 {
Expand All @@ -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 {
Expand All @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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");
Expand All @@ -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());
Expand All @@ -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());
Expand All @@ -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!(
Expand All @@ -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!(
Expand All @@ -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()];

Expand Down
Loading