Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion src/core/mxc-sdk/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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::{
Expand Down
1 change: 1 addition & 0 deletions src/core/mxc_engine/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/core/mxc_engine/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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};
Expand Down
239 changes: 192 additions & 47 deletions src/core/mxc_engine/src/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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 })
}

Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()),
Expand All @@ -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 {
Expand All @@ -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
Expand Down
Loading
Loading