diff --git a/Cargo.lock b/Cargo.lock index f2fb807752..6a42ca8c30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4564,6 +4564,7 @@ dependencies = [ "thiserror 2.0.20", "tokio", "tokio-stream", + "toml", "tonic", "tracing", "uuid", diff --git a/crates/openshell-driver-mxc/Cargo.toml b/crates/openshell-driver-mxc/Cargo.toml index 9855e48478..7c72db4df4 100644 --- a/crates/openshell-driver-mxc/Cargo.toml +++ b/crates/openshell-driver-mxc/Cargo.toml @@ -47,6 +47,7 @@ tempfile = "3" # is independent; explicit entries make them visible to integration tests. base64 = { workspace = true } serde_json = { workspace = true } +toml = { workspace = true } # Used by the drift guard test (handled_fields_inventory) to parse YAML into a # generic serde_json::Value for key enumeration. serde_yml = { workspace = true } diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index e5276ef4fe..06dc7675f9 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -4,11 +4,13 @@ OpenShell compute driver backed by **Microsoft MXC** (`wxc-exec`) on Windows. ## Design -Caller driver config is disabled by default, so command-based MXC workflows -need explicit administrator opt-in. Host filesystem grants have no trusted -label resolver and are rejected while resource admission is enabled. +MXC allows caller driver config by default because every workload requires a +command. It disables resource label admission by default because its host +filesystem grants have no trusted label resolver. An operator can explicitly +enable admission, but sandbox creates will then reject those grants until a +resolver exists. These MXC defaults differ from the other compute drivers. See [resource admission configuration](../../docs/how-it-works/gateways/configuration.mdx#external-resource-admission) -for the independent controls and the security consequences of opting out. +for the independent controls and the security consequences of disabled admission. This driver implements the gateway's ordinary in-process `ComputeDriver` contract and is linked into `openshell-gateway`. It sets diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index c7a966a32b..9caaaf3de9 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -15,8 +15,8 @@ use openshell_core::proto::compute::v1::{ WatchSandboxesPlatformEvent, WatchSandboxesSandboxEvent, watch_sandboxes_event, }; use openshell_core::proto_struct::struct_to_json_value; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use serde::{Deserialize, Deserializer, Serialize}; +use std::collections::{BTreeMap, HashMap}; use std::net::SocketAddr; use std::path::PathBuf; use std::pin::Pin; @@ -62,6 +62,7 @@ pub struct MxcComputeConfig { /// Permit caller-supplied driver JSON. Does not waive resource admission. pub allow_driver_config: bool, /// Operator-owned external attachment approval policy. + #[serde(deserialize_with = "deserialize_mxc_resource_admission")] pub resource_admission: openshell_core::resource_admission::ResourceAdmissionConfig, /// Path to `wxc-exec.exe`. Required for live runs. pub wxc_exec_path: String, @@ -91,13 +92,40 @@ pub struct MxcComputeConfig { pub etw_audit: bool, } +fn deserialize_mxc_resource_admission<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct PartialAdmission { + #[serde(default)] + enabled: bool, + required_labels: Option>, + } + + let partial = PartialAdmission::deserialize(deserializer)?; + let mut config = openshell_core::resource_admission::ResourceAdmissionConfig { + enabled: partial.enabled, + ..Default::default() + }; + if let Some(required_labels) = partial.required_labels { + config.required_labels = required_labels; + } + Ok(config) +} + impl Default for MxcComputeConfig { fn default() -> Self { Self { wxc_exec_path: "wxc-exec.exe".into(), - allow_driver_config: false, - resource_admission: - openshell_core::resource_admission::ResourceAdmissionConfig::default(), + allow_driver_config: true, + resource_admission: openshell_core::resource_admission::ResourceAdmissionConfig { + enabled: false, + ..Default::default() + }, backend: MxcBackend::default(), pc_least_privilege: false, pc_capabilities: Vec::new(), @@ -1260,26 +1288,19 @@ mod lifecycle_tests { }; use std::time::Duration; - fn host_grants_config() -> MxcComputeConfig { - MxcComputeConfig { - allow_driver_config: true, + #[tokio::test] + async fn explicitly_enabled_admission_rejects_host_grants() { + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig { resource_admission: openshell_core::resource_admission::ResourceAdmissionConfig { - enabled: false, + enabled: true, ..Default::default() }, ..Default::default() - } - } - - #[tokio::test] - async fn admission_rejects_host_grants_even_with_driver_config_enabled() { - let backend = MxcComputeBackend::new_mocked(MxcComputeConfig { - allow_driver_config: true, - ..Default::default() }); let sandbox = driver_sandbox("sb-admission"); let error = backend.validate_sandbox_create(&sandbox).unwrap_err(); assert_eq!(error.code(), tonic::Code::FailedPrecondition); + assert!(error.message().contains("MXC host filesystem grants")); assert!(backend.create_sandbox(&sandbox).await.is_err()); assert!(backend.get_sandbox("sb-admission").await.is_none()); } @@ -1359,13 +1380,33 @@ mod lifecycle_tests { } #[test] - fn mxc_config_defaults_to_default_deny_process_container() { + fn mxc_config_defaults_allow_command_and_disable_label_admission() { let config = MxcComputeConfig::default(); assert_eq!(config.backend, MxcBackend::ProcessContainer); + assert!(config.allow_driver_config); + assert!(!config.resource_admission.enabled); assert!(!config.egress_proxy); assert!(config.egress_proxy_addr.is_empty()); } + #[test] + fn mxc_toml_omitting_admission_keys_uses_mxc_defaults() { + // `driver_config_from_context` passes the contents of + // [openshell.drivers.mxc] to this type. Keep this representative + // operator configuration aligned with the gateway's admission + // acknowledgement defaults. + let config: MxcComputeConfig = toml::from_str( + r#" +wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe" +backend = "process_container" +"#, + ) + .expect("MXC config should deserialize"); + + assert!(config.allow_driver_config); + assert!(!config.resource_admission.enabled); + } + #[test] fn sandbox_proxy_addr_uses_ephemeral_loopback_port() { let configured = "127.0.0.1:18080".parse().unwrap(); @@ -1474,7 +1515,7 @@ mod lifecycle_tests { "-Command".into(), format!("Set-Content -LiteralPath {hello} -Value hi"), ]; - let backend = MxcComputeBackend::new_mocked(host_grants_config()); + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); let policy = fs_policy(&[&share]); let sb = with_policy(driver_sandbox_with_command("sb-pos", &share, cmd), policy); @@ -1535,7 +1576,7 @@ mod lifecycle_tests { "-Command".into(), format!("Set-Content -LiteralPath {hello} -Value hi"), ]; - let backend = MxcComputeBackend::new_mocked(host_grants_config()); + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); let policy = fs_policy(&[&share]); let sb = with_policy(driver_sandbox_with_command("sb-pc", &share, cmd), policy); @@ -1587,7 +1628,7 @@ mod lifecycle_tests { backend: MxcBackend::ProcessContainer, egress_proxy: true, egress_proxy_addr: "127.0.0.1:18080".into(), - ..host_grants_config() + ..Default::default() }; let backend = MxcComputeBackend::new_mocked(config); let mut stream = backend.watch_sandboxes().await; @@ -1696,7 +1737,7 @@ mod lifecycle_tests { "-Command".into(), format!("Set-Content -LiteralPath {out_path} -Value hi"), ]; - let backend = MxcComputeBackend::new_mocked(host_grants_config()); + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); // Subscribe to the watch stream BEFORE create so we catch the denial event. let mut stream = backend.watch_sandboxes().await; @@ -1755,7 +1796,7 @@ mod lifecycle_tests { "-Command".into(), format!("$null = '{share}'; Start-Sleep -Seconds 60"), ]; - let backend = MxcComputeBackend::new_mocked(host_grants_config()); + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); let policy = fs_policy(&[&share]); let sandbox = with_policy(driver_sandbox_with_command("sb-stop", "", command), policy); backend @@ -1785,7 +1826,7 @@ mod lifecycle_tests { let tmp = tempfile::tempdir().unwrap(); let share = tmp.path().to_string_lossy().replace('\\', "/"); - let backend = MxcComputeBackend::new_mocked(host_grants_config()); + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); let mut policy = fs_policy(&[&share]); policy.network_policies.insert( @@ -1813,7 +1854,7 @@ mod lifecycle_tests { let config = MxcComputeConfig { egress_proxy: true, egress_proxy_addr: "127.0.0.1:18080".into(), - ..host_grants_config() + ..Default::default() }; let backend = MxcComputeBackend::new_mocked(config); diff --git a/crates/openshell-server/src/compute/driver_config.rs b/crates/openshell-server/src/compute/driver_config.rs index 12395f4a1b..53e355dd7f 100644 --- a/crates/openshell-server/src/compute/driver_config.rs +++ b/crates/openshell-server/src/compute/driver_config.rs @@ -78,6 +78,19 @@ pub fn admission_config_from_context( name: &str, ) -> Result { let mut table = toml::map::Map::new(); + if name == "mxc" { + // MXC needs caller command JSON and cannot resolve labels for its host + // filesystem grants. Keep the gateway's expected policy in sync with + // MxcComputeConfig::default() when these fields are omitted. + table.insert("allow_driver_config".into(), toml::Value::Boolean(true)); + table.insert( + "resource_admission".into(), + toml::Value::Table(toml::map::Map::from_iter([( + "enabled".into(), + toml::Value::Boolean(false), + )])), + ); + } if let Some(config) = context .file .and_then(|file| file.openshell.drivers.get(name)) @@ -88,6 +101,15 @@ pub fn admission_config_from_context( } } } + if name == "mxc" + && let Some(resource_admission) = table + .get_mut("resource_admission") + .and_then(toml::Value::as_table_mut) + { + resource_admission + .entry("enabled") + .or_insert(toml::Value::Boolean(false)); + } let policy: openshell_core::resource_admission::DriverAdmissionConfig = toml::Value::Table(table).try_into().map_err(|error| { Error::config(format!("invalid driver admission configuration: {error}")) @@ -243,8 +265,8 @@ mod tests { } #[test] - fn common_admission_defaults_and_replacement_apply_to_every_driver() { - for name in ["kubernetes", "docker", "podman", "vm", "mxc", "external"] { + fn common_admission_defaults_and_replacement_apply_to_other_drivers() { + for name in ["kubernetes", "docker", "podman", "vm", "external"] { let defaults = admission_config_from_context(test_context(None), name).unwrap(); assert!(!defaults.allow_driver_config); assert!(defaults.resource_admission.enabled); @@ -261,6 +283,33 @@ mod tests { } } + #[test] + fn mxc_admission_defaults_and_explicit_settings() { + let defaults = admission_config_from_context(test_context(None), "mxc").unwrap(); + assert!(defaults.allow_driver_config); + assert!(!defaults.resource_admission.enabled); + + let file: config_file::ConfigFile = toml::from_str("[openshell.drivers.mxc]\n").unwrap(); + assert_eq!( + admission_config_from_context(test_context(Some(&file)), "mxc").unwrap(), + defaults + ); + + let file: config_file::ConfigFile = toml::from_str( + "[openshell.drivers.mxc]\nallow_driver_config = false\n[openshell.drivers.mxc.resource_admission]\nenabled = true\n", + ) + .unwrap(); + let explicit = admission_config_from_context(test_context(Some(&file)), "mxc").unwrap(); + assert!(!explicit.allow_driver_config); + assert!(explicit.resource_admission.enabled); + + let file: config_file::ConfigFile = + toml::from_str("[openshell.drivers.mxc.resource_admission]\n").unwrap(); + let partial = admission_config_from_context(test_context(Some(&file)), "mxc").unwrap(); + assert!(partial.allow_driver_config); + assert!(!partial.resource_admission.enabled); + } + #[test] fn common_admission_rejects_empty_map_and_preserves_explicit_opt_out() { let file: config_file::ConfigFile = diff --git a/docs/how-it-works/gateways/configuration.mdx b/docs/how-it-works/gateways/configuration.mdx index 7f16929759..bed80cfd58 100644 --- a/docs/how-it-works/gateways/configuration.mdx +++ b/docs/how-it-works/gateways/configuration.mdx @@ -720,8 +720,8 @@ The gateway refuses to start when a peer endpoint is configured on a multi-repli ### External Resource Admission -Every compute driver disables caller-supplied `template.driver_config` by -default. An operator can enable it without disabling resource approval: +Compute drivers other than MXC disable caller-supplied `template.driver_config` +by default. An operator can enable it without disabling resource approval: ```toml [openshell.drivers.kubernetes] @@ -736,7 +736,9 @@ enabled = true ``` Use the same fields under the selected Docker, Podman, VM, MXC, or external -driver table. Omitted admission settings use the labels above. A supplied map +driver table. Omitted admission settings use the labels above, except for MXC: +MXC allows caller driver config and disables resource label admission by +default. A supplied map replaces the defaults, and every entry must match. An explicitly empty map is invalid while enforcement is enabled. `${workspace}` is the entire label value and resolves to the authorized OpenShell workspace name, not a Kubernetes @@ -796,9 +798,10 @@ The exception does not approve unrelated host mounts or non-GPU devices. Raw host bind mounts and supplemental image mounts have no trusted label resolver and are rejected while admission is enabled. Publisher image labels cannot grant operator approval. Docker's existing unsupported image-mount -restriction remains. MXC host filesystem grants likewise require disabling -admission until a resolver exists. MXC's command config and VM upload workflows -encoded in driver JSON require explicit driver-config opt-in. +restriction remains. MXC host filesystem grants likewise cannot pass enabled +admission until a resolver exists. MXC permits its required command config by +default; VM upload workflows encoded in driver JSON require explicit +driver-config opt-in. `allow_driver_config` applies to nonempty caller config, including saved templates and non-storage overrides. An absent config or `{}` does not require @@ -1032,18 +1035,29 @@ compute_driver = "mxc" [openshell.drivers.mxc] wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe" +allow_driver_config = true backend = "process_container" default_configuration_id = "composable" pc_least_privilege = false pc_capabilities = [] debug = false etw_audit = true + +[openshell.drivers.mxc.resource_admission] +enabled = false ``` `etw_audit` defaults to `false`. When enabled, the gateway account must be an administrator or belong to the Windows Performance Log Users group. Workload commands and working directories remain sandbox-scoped and must be supplied in -the `mxc` driver configuration when creating a sandbox. +the `mxc` driver configuration when creating a sandbox. Unlike other drivers, +MXC defaults to `allow_driver_config = true` and +`resource_admission.enabled = false`: its required command uses driver config, +and its host filesystem grants have no trusted label resolver. The gateway +warns at startup that label admission is disabled. You can explicitly set +either field in this table; omitting `enabled` from a partial admission table +keeps it disabled. Setting `resource_admission.enabled = true` rejects +MXC sandbox creates until a trusted resolver is available. `OPENSHELL_OCSF_JSON=1` enables the gateway's JSONL audit sink independently of the selected compute driver. Set `OPENSHELL_OCSF_LOG_DIR` diff --git a/docs/how-it-works/sandboxes/runtimes.mdx b/docs/how-it-works/sandboxes/runtimes.mdx index f56d194d63..060c51cbee 100644 --- a/docs/how-it-works/sandboxes/runtimes.mdx +++ b/docs/how-it-works/sandboxes/runtimes.mdx @@ -96,7 +96,13 @@ openshell sandbox create \ -- claude ``` -Driver config is disabled by default. Enable it with `allow_driver_config = true` in the driver's gateway configuration. Attached resources such as volumes must carry operator-approved labels. See [External Resource Admission](/how-it-works/gateways/configuration#external-resource-admission). +Driver config is disabled by default except for MXC, whose required workload +command uses driver config. MXC also disables resource label admission by +default because its host filesystem grants have no trusted label resolver; +explicitly enabling admission rejects MXC sandbox creates. Enable driver config +for other drivers with `allow_driver_config = true` in the driver's gateway +configuration. When resource admission is enabled, attached resources such as +volumes must carry operator-approved labels. See [External Resource Admission](/how-it-works/gateways/configuration#external-resource-admission). To pin specific GPUs, pass `cdi_devices` for Docker or Podman, or `gpu_device_ids` for MicroVM, together with `--gpu`: diff --git a/skills/openshell-cli/SKILL.md b/skills/openshell-cli/SKILL.md index 8fe37fee1c..7f7fb80a39 100644 --- a/skills/openshell-cli/SKILL.md +++ b/skills/openshell-cli/SKILL.md @@ -345,13 +345,17 @@ openshell sandbox template create gpu-kata \ openshell sandbox create --name my-sandbox --template gpu-kata --provider my-github -- claude ``` -Driver config is disabled by default. These template and one-off +Driver config is disabled by default except for MXC, which requires a caller +command. For other drivers, these template and one-off `sandbox create --driver-config-json` examples require the administrator to set -`allow_driver_config = true` for the selected driver. This does not waive -resource admission: external attachments need administrator-controlled approval -labels on the actual resources, not sandbox labels. GPU device attachments -are temporarily exempt from labels; the public `--gpu` flag needs no driver -config opt-in. Consult the published gateway configuration reference before +`allow_driver_config = true` for the selected driver. For drivers with resource +admission enabled, external attachments need administrator-controlled approval +labels on the actual resources, not sandbox labels. GPU device attachments are +temporarily exempt from labels; the public `--gpu` flag needs no driver config +opt-in. MXC disables resource label admission by default because it cannot +resolve labels for its host filesystem grants. Default MXC gateways do not use +approval labels for those grants; enabling admission rejects them. Consult the +published gateway configuration reference before changing admission settings; do not recommend disabling admission to bypass a denial. Put driver config on a template only when it should be reused.