diff --git a/src/Cargo.lock b/src/Cargo.lock index cb387bca..6bf0a235 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -1530,6 +1530,7 @@ dependencies = [ "isolation_session_bindings", "isolation_session_common", "lxc_common", + "mxc_config_contract", "nanvix_runner", "seatbelt_common", "serde", diff --git a/src/core/mxc-sdk/src/lib.rs b/src/core/mxc-sdk/src/lib.rs index 62aef0b1..6dcab923 100644 --- a/src/core/mxc-sdk/src/lib.rs +++ b/src/core/mxc-sdk/src/lib.rs @@ -50,6 +50,7 @@ //! | Bubblewrap | Linux | [`Containment::Process`] | //! | Seatbelt | macOS | [`Containment::Process`] | //! | ProcessContainer (AppContainer / BaseContainer) | Windows | [`Containment::Process`] | +//! | Explicit ProcessContainer settings | Windows | [`Containment::ProcessContainer`] | //! | WSLC (WSL Container) | Windows | [`Containment::Wslc`] | //! //! WSLC is **experimental**: build with the crate's `wslc` feature, and call @@ -116,7 +117,7 @@ pub use mxc_engine::{ available_backends, available_tools_policy, build_request, build_request_with_containment, platform_support, temporary_files_policy, user_profile_policy, AvailableBackend, BackendCapability, Containment, Error, ErrorCode, FilesystemPolicyResult, PlatformSupport, - SandboxPolicy, SandboxRequest, WslcSection, + ProcessContainer, SandboxPolicy, SandboxRequest, WslcSection, }; pub use sandbox::{ diff --git a/src/core/mxc_engine/Cargo.toml b/src/core/mxc_engine/Cargo.toml index ca32a0c8..738733de 100644 --- a/src/core/mxc_engine/Cargo.toml +++ b/src/core/mxc_engine/Cargo.toml @@ -11,6 +11,7 @@ path = "src/lib.rs" [dependencies] wxc_common.workspace = true +mxc_config_contract.workspace = true serde = { workspace = true } serde_json = { workspace = true } # MicroVM runner — used by the Windows and Linux run-to-completion bodies under diff --git a/src/core/mxc_engine/src/lib.rs b/src/core/mxc_engine/src/lib.rs index 552b51cc..1b3bc4e1 100644 --- a/src/core/mxc_engine/src/lib.rs +++ b/src/core/mxc_engine/src/lib.rs @@ -34,6 +34,7 @@ mod error; mod platform; pub mod policy; mod probe; +mod process_container_config; #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] mod run; mod state_aware; @@ -48,6 +49,7 @@ pub use policy::{ WslcSection, }; pub use probe::{available_backends, AvailableBackend, BackendCapability}; +pub use process_container_config::ProcessContainer; #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] pub use run::{resolve_runner, run, ResolvedRunner}; pub use state_aware::{exec_state_aware_json, run_state_aware, run_state_aware_json}; diff --git a/src/core/mxc_engine/src/policy.rs b/src/core/mxc_engine/src/policy.rs index 410cdfd8..35af54f4 100644 --- a/src/core/mxc_engine/src/policy.rs +++ b/src/core/mxc_engine/src/policy.rs @@ -20,6 +20,9 @@ use wxc_common::logger::{Logger, Mode}; use wxc_common::models::ExecutionRequest; use wxc_common::mxc_error::MxcError; +pub use crate::process_container_config::ProcessContainer; +use crate::process_container_config::{create_process_container_config, CaptureDenialsInput}; + // --------------------------------------------------------------------------- // Filesystem policy discovery // --------------------------------------------------------------------------- @@ -519,7 +522,6 @@ pub enum CaptureDenialsMode { Allow, } -#[cfg(any(target_os = "windows", test))] impl CaptureDenialsMode { /// Wire-format value accepted by the config parser. fn wire(self) -> &'static str { @@ -566,6 +568,8 @@ pub enum Containment { /// ProcessContainer (Windows), Bubblewrap (Linux), Seatbelt (macOS). #[default] Process, + /// Windows ProcessContainer with AppContainer-specific settings. + ProcessContainer(ProcessContainer), /// WSL Container backend: a Linux container on a Windows host, via the WSLC /// SDK, configured by the carried [`WslcSection`] /// (`WslcSection::default()` matches the SDK's defaults). @@ -843,10 +847,11 @@ pub fn build_request_with_containment( let mut logger = Logger::new(Mode::Buffer); // Map the wire config straight to a request — no base64/file round-trip. - // The command line is intentionally empty here (the caller fills - // `script_code` before running), so tolerate a missing command. - let inner = wxc_common::config_parser::load_request_from_value(config, &mut logger, true) + let mut inner = wxc_common::config_parser::load_request_from_value(config, &mut logger, false) .map_err(|e| MxcError::malformed_request(format!("failed to build request: {e}")))?; + // The placeholder only satisfies the versioned configuration contract. + // The caller supplies the real command through `SandboxRequest::set_script`. + inner.script_code.clear(); Ok(SandboxRequest { inner }) } @@ -870,7 +875,10 @@ fn build_wire_config( "version": policy.version, "containerId": container_id, "lifecycle": { "destroyOnExit": true, "preservePolicy": !clear_policy }, - "process": { "commandLine": "", "timeout": policy.timeout_ms.unwrap_or(0) }, + "process": { + "commandLine": "mxc-sdk-command-placeholder", + "timeout": policy.timeout_ms.unwrap_or(0), + }, "filesystem": { "readwritePaths": fs.readwrite_paths, "readonlyPaths": fs.readonly_paths, @@ -905,8 +913,11 @@ fn build_wire_config( // non-empty `allowedHosts` to allow-all outbound), but we accept it on macOS // anyway to stay consistent with the SDK rather than diverging — keeping the // two ports reconciled matters more than being stricter here. - let accepts_host_rules_without_outbound = cfg!(any(target_os = "linux", target_os = "macos")) - || matches!(containment, Containment::Wslc(_)); + let accepts_host_rules_without_outbound = match containment { + Containment::Process => cfg!(any(target_os = "linux", target_os = "macos")), + Containment::ProcessContainer(_) => false, + Containment::Wslc(_) => true, + }; if let Some(net) = &policy.network { if !accepts_host_rules_without_outbound @@ -933,7 +944,15 @@ fn build_wire_config( } match containment { - Containment::Process => apply_host_process_backend(&mut config, policy, &container_id), + Containment::Process => { + apply_host_process_backend(&mut config, policy, &container_id, &policy.version)? + } + Containment::ProcessContainer(process_container) => apply_process_container_backend( + &mut config, + policy, + process_container, + &policy.version, + )?, Containment::Wslc(wslc) => apply_wslc_backend(&mut config, wslc), } Ok(config) @@ -956,7 +975,8 @@ fn apply_host_process_backend( config: &mut serde_json::Value, policy: &SandboxPolicy, container_id: &str, -) { + version: &str, +) -> Result<(), MxcError> { use serde_json::json; // Resolve the abstract Process intent per host. @@ -979,54 +999,59 @@ fn apply_host_process_backend( #[cfg(target_os = "windows")] { - let mut capabilities: Vec<&str> = Vec::new(); - if let Some(net) = &policy.network { - if net.allow_outbound { - capabilities.push("internetClient"); - } - if net.allow_local_network { - capabilities.push("privateNetworkClientServer"); - } - } // The container id is carried only at the top level (`containerId`); the // wire `processContainer` object intentionally has no `name` field. let _ = container_id; - config["processContainer"] = json!({ - "leastPrivilege": false, - "capabilities": capabilities, - "ui": { - "isolation": "container", - "desktopSystemControl": false, - "systemSettings": "none", - "ime": false, - }, - }); - if let Some(cd) = &policy.capture_denials { - config["processContainer"]["captureDenials"] = json!({ - "mode": cd.mode.wire(), - "outputPath": cd.output_path, - "retainEtl": cd.retain_etl, - }); - } - if let Some(network) = config.get_mut("network") { - let mode = if has_host_rules(network) { - "both" - } else { - "capabilities" - }; - network["enforcementMode"] = json!(mode); - } + apply_process_container_backend(config, policy, &ProcessContainer::default(), version)?; } #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] { let _ = (policy, container_id); } + + Ok(()) +} + +fn apply_process_container_backend( + config: &mut serde_json::Value, + policy: &SandboxPolicy, + process_container: &ProcessContainer, + version: &str, +) -> Result<(), MxcError> { + use serde_json::json; + + config["containment"] = json!("processcontainer"); + let network = policy.network.as_ref(); + let capture_denials = policy + .capture_denials + .as_ref() + .map(|capture| CaptureDenialsInput { + mode: capture.mode.wire(), + output_path: capture.output_path.as_deref(), + retain_etl: capture.retain_etl, + }); + config["processContainer"] = create_process_container_config( + version, + process_container, + network.is_some_and(|network| network.allow_outbound), + network.is_some_and(|network| network.allow_local_network), + capture_denials, + )? + .into_value()?; + if let Some(network) = config.get_mut("network") { + let mode = if has_host_rules(network) { + "both" + } else { + "capabilities" + }; + network["enforcementMode"] = json!(mode); + } + Ok(()) } /// True when the network section carries any host allow/deny rules, deciding -/// whether host-level enforcement is engaged. (Linux + Windows only.) -#[cfg(any(target_os = "linux", target_os = "windows"))] +/// whether host-level enforcement is engaged. fn has_host_rules(network: &serde_json::Value) -> bool { let non_empty = |key: &str| { network @@ -1410,7 +1435,7 @@ mod tests { fn policy_with_capture_denials(section: CaptureDenialsSection) -> SandboxPolicy { SandboxPolicy { - version: "0.7.0-alpha".to_string(), + version: "0.8.0-alpha".to_string(), filesystem: None, network: None, ui: None, @@ -1581,6 +1606,7 @@ mod tests { proxy: Some(ProxySpec::Localhost(8080)), ..NetworkSection::default() }); + policy.version = "0.8.0-alpha".to_string(); policy.capture_denials = Some(CaptureDenialsSection { mode: CaptureDenialsMode::Allow, output_path: Some(expected.clone()), @@ -1601,7 +1627,10 @@ mod tests { assert!(request.inner.policy.network_proxy.is_enabled()); } - use super::{build_request_with_containment, Containment, WslcSection}; + use super::{ + build_request_with_containment, build_wire_config, Containment, ProcessContainer, + WslcSection, + }; use wxc_common::models::ContainmentBackend; fn minimal_policy() -> SandboxPolicy { @@ -1623,6 +1652,122 @@ mod tests { assert_ne!(request.inner.containment, ContainmentBackend::Wslc); } + #[test] + fn process_container_maps_custom_capabilities_through_the_shared_parser() { + let policy = policy_with_network(NetworkSection { + allow_outbound: true, + allow_local_network: true, + ..Default::default() + }); + let process_container = ProcessContainer { + least_privilege: true, + capabilities: vec!["registryRead".to_string(), "INTERNETCLIENT".to_string()], + }; + + let request = build_request_with_containment( + &policy, + &Containment::ProcessContainer(process_container), + None, + ) + .expect("process-container settings should satisfy the wire contract"); + + assert_eq!( + request.inner.containment, + ContainmentBackend::ProcessContainer + ); + assert!(request.inner.policy.least_privilege_mode); + assert_eq!( + request + .inner + .policy + .capabilities + .iter() + .filter(|capability| capability.eq_ignore_ascii_case("internetClient")) + .count(), + 1, + "network-derived capabilities should be de-duplicated" + ); + for expected in [ + "internetClient", + "privateNetworkClientServer", + "registryRead", + ] { + assert!( + request + .inner + .policy + .capabilities + .iter() + .any(|capability| capability.eq_ignore_ascii_case(expected)), + "missing capability {expected}: {:?}", + request.inner.policy.capabilities + ); + } + } + + #[test] + fn process_container_capabilities_use_shared_parser_validation() { + let process_container = ProcessContainer { + capabilities: vec!["internetClient,registryRead".to_string()], + ..Default::default() + }; + + let error = build_request_with_containment( + &minimal_policy(), + &Containment::ProcessContainer(process_container), + None, + ) + .expect_err("comma-packed capabilities must be rejected"); + + assert!( + error.message.contains("must not contain a comma"), + "got: {}", + error.message + ); + } + + #[test] + fn process_container_wire_shape_stays_compatible_with_versioned_contracts() { + let config = build_wire_config( + &minimal_policy(), + &Containment::ProcessContainer(ProcessContainer::default()), + None, + ) + .expect("build wire config"); + + assert_eq!( + config["process"]["commandLine"], + "mxc-sdk-command-placeholder" + ); + assert!( + config["processContainer"].get("learningMode").is_none(), + "latest-only fields must not be emitted unconditionally" + ); + } + + #[test] + fn explicit_process_container_keeps_windows_host_rule_validation() { + let policy = policy_with_network(NetworkSection { + allowed_hosts: vec!["example.com".to_string()], + ..Default::default() + }); + + let error = build_request_with_containment( + &policy, + &Containment::ProcessContainer(ProcessContainer::default()), + None, + ) + .expect_err("ProcessContainer host rules require outbound access"); + + assert!( + error + .message + .contains("allowedHosts/blockedHosts require allowOutbound"), + "got: {}", + error.message + ); + } + #[test] fn wslc_containment_maps_config_to_the_request() { // Mirrors `createConfigFromPolicy(policy, 'wslc')` plus a tweaked diff --git a/src/core/mxc_engine/src/process_container_config.rs b/src/core/mxc_engine/src/process_container_config.rs new file mode 100644 index 00000000..1c7984db --- /dev/null +++ b/src/core/mxc_engine/src/process_container_config.rs @@ -0,0 +1,243 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use mxc_config_contract::ContractVersion; +use serde::Serialize; +use serde_json::Value; +use wxc_common::mxc_error::MxcError; + +/// Windows ProcessContainer settings. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ProcessContainer { + /// Enforce least-privilege mode. + pub least_privilege: bool, + /// Additional AppContainer capabilities, such as `registryRead`. + pub capabilities: Vec, +} + +pub(crate) struct CaptureDenialsInput<'a> { + pub mode: &'static str, + pub output_path: Option<&'a str>, + pub retain_etl: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ProcessContainerUi { + isolation: &'static str, + desktop_system_control: bool, + system_settings: &'static str, + ime: bool, +} + +impl Default for ProcessContainerUi { + fn default() -> Self { + Self { + isolation: "container", + desktop_system_control: false, + system_settings: "none", + ime: false, + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProcessContainerV0_6 { + least_privilege: bool, + capabilities: Vec, + ui: ProcessContainerUi, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct CaptureDenialsV0_8<'a> { + mode: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + output_path: Option<&'a str>, + retain_etl: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProcessContainerV0_8<'a> { + #[serde(flatten)] + common: ProcessContainerV0_6, + #[serde(skip_serializing_if = "Option::is_none")] + capture_denials: Option>, +} + +#[derive(Debug)] +pub(crate) enum VersionedProcessContainerConfig<'a> { + V0_6(ProcessContainerV0_6), + V0_7(ProcessContainerV0_6), + V0_8(ProcessContainerV0_8<'a>), +} + +impl VersionedProcessContainerConfig<'_> { + pub(crate) fn into_value(self) -> Result { + match self { + Self::V0_6(config) | Self::V0_7(config) => serialize_config(&config), + Self::V0_8(config) => serialize_config(&config), + } + } +} + +/// Creates the ProcessContainer wire object for an exact schema version. +pub(crate) fn create_process_container_config<'a>( + version: &str, + config: &ProcessContainer, + allow_outbound: bool, + allow_local_network: bool, + capture_denials: Option>, +) -> Result, MxcError> { + let version = ContractVersion::parse_exact(version).ok_or_else(|| { + MxcError::malformed_request(format!( + "Unsupported policy version '{version}'; expected 0.6.0-alpha, \ + 0.7.0-alpha, or 0.8.0-alpha" + )) + })?; + + let mut capabilities = Vec::new(); + if allow_outbound { + capabilities.push("internetClient".to_string()); + } + if allow_local_network { + capabilities.push("privateNetworkClientServer".to_string()); + } + for capability in &config.capabilities { + if !capabilities + .iter() + .any(|existing| existing.eq_ignore_ascii_case(capability)) + { + capabilities.push(capability.clone()); + } + } + + let common = ProcessContainerV0_6 { + least_privilege: config.least_privilege, + capabilities, + ui: ProcessContainerUi::default(), + }; + + match version { + ContractVersion::V0_6_0Alpha => { + if capture_denials.is_some() { + return Err(MxcError::malformed_request(format!( + "processContainer.captureDenials is not available in schema version '{}'", + version.as_str() + ))); + } + Ok(VersionedProcessContainerConfig::V0_6(common)) + } + ContractVersion::V0_7_0Alpha => { + if capture_denials.is_some() { + return Err(MxcError::malformed_request(format!( + "processContainer.captureDenials is not available in schema version '{}'", + version.as_str() + ))); + } + Ok(VersionedProcessContainerConfig::V0_7(common)) + } + ContractVersion::V0_8_0Alpha => Ok(VersionedProcessContainerConfig::V0_8( + ProcessContainerV0_8 { + common, + capture_denials: capture_denials.map(|capture| CaptureDenialsV0_8 { + mode: capture.mode, + output_path: capture.output_path, + retain_etl: capture.retain_etl, + }), + }, + )), + } +} + +fn serialize_config(config: &impl Serialize) -> Result { + serde_json::to_value(config).map_err(|error| { + MxcError::backend_error(format!( + "failed to serialize ProcessContainer configuration: {error}" + )) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn common_versions_emit_only_published_fields() { + for version in ["0.6.0-alpha", "0.7.0-alpha"] { + let value = create_process_container_config( + version, + &ProcessContainer { + least_privilege: true, + capabilities: vec!["registryRead".to_string()], + }, + true, + false, + None, + ) + .expect("published ProcessContainer config") + .into_value() + .expect("serialize ProcessContainer config"); + + assert_eq!(value["leastPrivilege"], true); + assert_eq!( + value["capabilities"], + serde_json::json!(["internetClient", "registryRead"]) + ); + assert!(value.get("captureDenials").is_none()); + } + } + + #[test] + fn capture_denials_requires_v0_8() { + let capture = || CaptureDenialsInput { + mode: "block", + output_path: None, + retain_etl: false, + }; + + let error = create_process_container_config( + "0.7.0-alpha", + &ProcessContainer::default(), + false, + false, + Some(capture()), + ) + .expect_err("0.7 must reject captureDenials"); + assert!(error.message.contains("not available")); + + let value = create_process_container_config( + "0.8.0-alpha", + &ProcessContainer::default(), + false, + false, + Some(capture()), + ) + .expect("0.8 captureDenials config") + .into_value() + .expect("serialize ProcessContainer config"); + assert_eq!(value["captureDenials"]["mode"], "block"); + assert!(value["captureDenials"].get("outputPath").is_none()); + } + + #[test] + fn capability_merge_is_case_insensitive() { + let value = create_process_container_config( + "0.7.0-alpha", + &ProcessContainer { + capabilities: vec!["INTERNETCLIENT".to_string()], + ..Default::default() + }, + true, + false, + None, + ) + .expect("ProcessContainer config") + .into_value() + .expect("serialize ProcessContainer config"); + + assert_eq!(value["capabilities"], serde_json::json!(["internetClient"])); + } +}