From 9fbd2566a2f0cfbe64d1c1a97b448ffa305eeb13 Mon Sep 17 00:00:00 2001 From: Prashant K Date: Fri, 5 Jun 2026 19:19:22 +0530 Subject: [PATCH 01/19] feat(driver): add MXC compute driver for Windows isolation sessions Introduces the openshell-driver-mxc crate implementing ComputeDriver backed by Microsoft MXC isolation sessions (Windows only). Wires the new driver into the server's build_compute_runtime dispatch and adds the Mxc variant to ComputeDriverKind. Also adds a local protobuf-src stub (tools/protobuf-src-local) to unblock Windows builds that lack MSYS2/MinGW, and pins the zig Windows x64 toolchain in mise.lock. (cherry picked from commit 4f7012224efb18fbfeb47aa87e0cfd3f036f32f0) Signed-off-by: Jamie King --- crates/openshell-core/src/config.rs | 6 +- crates/openshell-driver-mxc/Cargo.toml | 33 ++ crates/openshell-driver-mxc/README.md | 78 +++ crates/openshell-driver-mxc/src/driver.rs | 618 ++++++++++++++++++++++ crates/openshell-driver-mxc/src/grpc.rs | 142 +++++ crates/openshell-driver-mxc/src/lib.rs | 30 ++ crates/openshell-driver-mxc/src/mxc.rs | 447 ++++++++++++++++ crates/openshell-driver-mxc/src/policy.rs | 123 +++++ 8 files changed, 1476 insertions(+), 1 deletion(-) create mode 100644 crates/openshell-driver-mxc/Cargo.toml create mode 100644 crates/openshell-driver-mxc/README.md create mode 100644 crates/openshell-driver-mxc/src/driver.rs create mode 100644 crates/openshell-driver-mxc/src/grpc.rs create mode 100644 crates/openshell-driver-mxc/src/lib.rs create mode 100644 crates/openshell-driver-mxc/src/mxc.rs create mode 100644 crates/openshell-driver-mxc/src/policy.rs diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index 3ce88293bb..75ae05c5a8 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -124,6 +124,8 @@ pub enum ComputeDriverKind { Vm, Docker, Podman, + /// Microsoft MXC isolation session (Windows only). + Mxc, } impl ComputeDriverKind { @@ -134,6 +136,7 @@ impl ComputeDriverKind { Self::Vm => "vm", Self::Docker => "docker", Self::Podman => "podman", + Self::Mxc => "mxc", } } } @@ -174,8 +177,9 @@ impl FromStr for ComputeDriverKind { "vm" => Ok(Self::Vm), "docker" => Ok(Self::Docker), "podman" => Ok(Self::Podman), + "mxc" => Ok(Self::Mxc), other => Err(format!( - "unsupported compute driver '{other}'. expected one of: kubernetes, vm, docker, podman" + "unsupported compute driver '{other}'. expected one of: kubernetes, vm, docker, podman, mxc" )), } } diff --git a/crates/openshell-driver-mxc/Cargo.toml b/crates/openshell-driver-mxc/Cargo.toml new file mode 100644 index 0000000000..c7d2b91eaf --- /dev/null +++ b/crates/openshell-driver-mxc/Cargo.toml @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-driver-mxc" +description = "MXC (Windows isolation session) compute driver for OpenShell" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +name = "openshell_driver_mxc" + +[dependencies] +openshell-core = { path = "../openshell-core" } +tokio = { workspace = true } +tonic = { workspace = true } +futures = { workspace = true } +tokio-stream = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +base64 = { workspace = true } +tracing = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } + +[lints] +workspace = true diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md new file mode 100644 index 0000000000..3b36024de8 --- /dev/null +++ b/crates/openshell-driver-mxc/README.md @@ -0,0 +1,78 @@ +# openshell-driver-mxc + +OpenShell compute driver backed by **Microsoft MXC** (`wxc-exec`) on Windows. + +## Design + +This driver implements the gateway's `ComputeDriver` gRPC contract as an +**in-process library** linked into `openshell-gateway`. It drives MXC through +the state-aware lifecycle (`provision` → `start` → `exec` → `stop` → +`deprovision`), runs the agent **inside the driver** (exec-in-driver), and +**self-reports readiness** — there is no in-sandbox supervisor, no host-side +surrogate, and no `ConnectSupervisor` relay. See +`docs/reference/mxc-compute-driver-design.mdx` for Shailendra's full +architectural rationale (decisions D1–D4). + +## Capability Matrix (June 15 demo slice) + +| Capability | MXC driver | Closing it requires | +|---|---|---| +| Filesystem policy (read-write / read-only grants) | ✅ provision-time AppContainer shares | — | +| Governed egress (CONNECT proxy + OPA + L7) | ❌ | `implement-openshell-mxc-egress-proxy` | +| Network policy | ❌ `isolation_session` rejects network config | MXC feedback item M1 + egress skill | +| Process policy (seccomp, uid/gid) | ❌ host-side governance design; OS isolation only | not pursued | +| Interactive exec/connect/forward | ❌ exec runs in-driver, no client attach | `adapt-openshell-gateway-windows` | +| Bundled agent image | ❌ no OCI image; relies on Windows host install | — | +| Restart durability | ❌ in-memory registry; restart orphans live sessions | follow-on | +| Concurrent sandboxes | ⚠️ isolation_session v1 is single-session | MXC backend feature | + +The June 15 demo proof point is **filesystem policy enforcement**: +- **Positive**: write to the in-policy `share_dir` succeeds; `hello.txt` appears on the host. +- **Negative**: write outside the policy fails with Windows access-denied; driver emits a + `DriverPlatformEvent` denial and the exec exits non-zero. + +## Configuration (`[openshell.drivers.mxc]`) + +```toml +[openshell.drivers.mxc] +# Path to wxc-exec.exe (required for live runs) +wxc_exec_path = "C:\\path\\to\\wxc-exec.exe" +# MXC configurationId — never use "small" (known OS bug) +default_configuration_id = "composable" +# Agent command executed inside the sandbox +agent_command = ["cmd", "/c", "echo hello > C:\\work\\demo\\hello.txt"] +# Working directory for the agent (defaults to share_dir) +agent_cwd = "C:\\work\\demo" +# Host directory mapped read-write into the sandbox +share_dir = "C:\\work\\demo" +# Enable --debug on wxc-exec invocations +debug = false +``` + +Or via environment / CLI: +``` +OPENSHELL_DRIVERS=mxc openshell-gateway ... +openshell-gateway --drivers mxc ... +``` + +## Prerequisites (live runs) + +- Windows 11 Insider build ≥ 26300.8553 +- `IsoSessionApp.dll` present and registered +- `wxc-exec.exe` built with `--features isolation_session` +- Giedrius's policy mapper crate wired as the primary `PolicyMapper` binding + (until then the `StubPolicyMapper` grants only `share_dir` as read-write) + +## PolicyMapper seam + +Policy translation (`SandboxPolicy` → MXC `ContainerConfig`) is delegated to +Giedrius's Rust mapper crate via the `policy::PolicyMapper` trait. Until that +crate lands, `StubPolicyMapper` applies only the `share_dir` grant and rejects +everything else. **No live agent runs** until the real mapper is wired. + +## Deferred work + +- **Interactive exec/connect/forward** → `adapt-openshell-gateway-windows` +- **Governed egress / network policy** → `implement-openshell-mxc-egress-proxy` +- **Restart durability** (deprovision orphaned sessions on startup) → follow-on +- **GPU passthrough** → not pursued in host-side-governance design diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs new file mode 100644 index 0000000000..e3f0c842b3 --- /dev/null +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -0,0 +1,618 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! MXC compute backend: lifecycle logic, in-memory registry, exec-in-driver, +//! and self-reported readiness. + +use crate::mxc::{MxcFilesystem, MxcProcess, WxcExecInvoker}; +use crate::policy::{MapCtx, PolicyMapper, StubPolicyMapper}; +use openshell_core::proto::compute::v1::{ + DriverCondition, DriverPlatformEvent, DriverSandbox, DriverSandboxStatus, + GetCapabilitiesResponse, WatchSandboxesDeletedEvent, WatchSandboxesEvent, + WatchSandboxesPlatformEvent, WatchSandboxesSandboxEvent, watch_sandboxes_event, +}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::pin::Pin; +use std::sync::Arc; +use tokio::process::Child; +use futures::Stream; +use tokio::sync::{Mutex, broadcast, mpsc}; +use tokio_stream::wrappers::ReceiverStream; +use tracing::{info, warn}; + +const DRIVER_NAME: &str = "mxc"; +const DRIVER_VERSION: &str = env!("CARGO_PKG_VERSION"); +/// Sentinel image name — MXC has no OCI image; this string must be non-empty +/// so the gateway's `default_image` cache is satisfied, but it is not pullable. +const DEFAULT_IMAGE_SENTINEL: &str = "mxc:isolation-session"; + +// ── Config ──────────────────────────────────────────────────────────────────── + +/// Configuration for the MXC compute driver. +/// +/// Loaded from `[openshell.drivers.mxc]` in the gateway TOML file, or from +/// environment variables / CLI flags via the standard gateway precedence chain. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct MxcComputeConfig { + /// Path to `wxc-exec.exe`. Required for live runs. + pub wxc_exec_path: String, + /// MXC `configurationId` for isolation session. Default: `"composable"`. + /// Never use `"small"` (known OS bug). + pub default_configuration_id: String, + /// Agent command executed inside the sandbox (exec-in-driver). + /// For the June 15 demo this writes `hello.txt`; a follow-up skill + /// swaps in a richer agent. Must be non-empty for `CreateSandbox` to + /// succeed. + pub agent_command: Vec, + /// Working directory for the agent command inside the sandbox. + pub agent_cwd: String, + /// Host directory mapped into the sandbox as a read-write grant. + /// Appears in the shared host folder for the positive-proof artifact. + pub share_dir: String, + /// Enable `--debug` flag on `wxc-exec` invocations. + pub debug: bool, +} + +impl Default for MxcComputeConfig { + fn default() -> Self { + Self { + wxc_exec_path: "wxc-exec.exe".into(), + default_configuration_id: crate::mxc::DEFAULT_CONFIGURATION_ID.into(), + agent_command: Vec::new(), + agent_cwd: String::new(), + share_dir: String::new(), + debug: false, + } + } +} + +// ── Registry entry ──────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PhaseState { + Starting, + Running, + Stopped, + Failed(String), +} + +struct SandboxEntry { + sandbox: DriverSandbox, + iso_sandbox_id: Option, + phase_state: PhaseState, + exec_child: Option, +} + +impl std::fmt::Debug for SandboxEntry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SandboxEntry") + .field("sandbox_id", &self.sandbox.id) + .field("iso_sandbox_id", &self.iso_sandbox_id) + .field("phase_state", &self.phase_state) + .finish_non_exhaustive() + } +} + +// ── Watch stream helpers ────────────────────────────────────────────────────── + +pub type WatchStream = Pin< + Box> + Send>, +>; + +fn sandbox_event(sandbox: DriverSandbox) -> WatchSandboxesEvent { + WatchSandboxesEvent { + payload: Some(watch_sandboxes_event::Payload::Sandbox( + WatchSandboxesSandboxEvent { + sandbox: Some(sandbox), + }, + )), + } +} + +fn deleted_event(sandbox_id: String) -> WatchSandboxesEvent { + WatchSandboxesEvent { + payload: Some(watch_sandboxes_event::Payload::Deleted( + WatchSandboxesDeletedEvent { sandbox_id }, + )), + } +} + +fn platform_event(sandbox_id: String, reason: &str, message: String) -> WatchSandboxesEvent { + WatchSandboxesEvent { + payload: Some(watch_sandboxes_event::Payload::PlatformEvent( + WatchSandboxesPlatformEvent { + sandbox_id, + event: Some(DriverPlatformEvent { + timestamp_ms: 0, + source: "mxc-driver".into(), + r#type: "Warning".into(), + reason: reason.to_string(), + message, + metadata: HashMap::new(), + }), + }, + )), + } +} + +// ── Driver ──────────────────────────────────────────────────────────────────── + +/// In-process MXC compute driver. +pub struct MxcComputeBackend { + config: MxcComputeConfig, + invoker: WxcExecInvoker, + registry: Arc>>, + watch_tx: Arc>, + policy_mapper: Arc, +} + +impl std::fmt::Debug for MxcComputeBackend { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MxcComputeBackend") + .field("wxc_exec_path", &self.config.wxc_exec_path) + .finish_non_exhaustive() + } +} + +impl MxcComputeBackend { + pub fn new(config: MxcComputeConfig) -> Self { + let invoker = WxcExecInvoker::new(&config.wxc_exec_path, config.debug); + let (watch_tx, _) = broadcast::channel(256); + Self { + invoker, + config, + registry: Arc::new(Mutex::new(HashMap::new())), + watch_tx: Arc::new(watch_tx), + policy_mapper: Arc::new(StubPolicyMapper), + } + } + + pub fn capabilities(&self) -> GetCapabilitiesResponse { + openshell_core::driver_utils::build_capabilities_response( + DRIVER_NAME, + DRIVER_VERSION, + DEFAULT_IMAGE_SENTINEL, + false, + ) + } + + pub fn validate_sandbox_create(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { + if let Some(spec) = &sandbox.spec { + if spec.gpu { + return Err(tonic::Status::invalid_argument( + "mxc driver does not support GPU sandboxes", + )); + } + if let Some(tmpl) = &spec.template { + if !tmpl.agent_socket_path.is_empty() { + return Err(tonic::Status::invalid_argument( + "mxc driver does not support agent_socket_path (no in-sandbox supervisor)", + )); + } + } + } + if self.config.agent_command.is_empty() { + return Err(tonic::Status::invalid_argument( + "mxc driver: agent_command is required in [openshell.drivers.mxc]", + )); + } + Ok(()) + } + + pub async fn get_sandbox(&self, sandbox_name: &str) -> Option { + let registry = self.registry.lock().await; + registry + .values() + .find(|e| e.sandbox.name == sandbox_name) + .map(|e| e.sandbox.clone()) + } + + pub async fn list_sandboxes(&self) -> Vec { + let registry = self.registry.lock().await; + registry.values().map(|e| e.sandbox.clone()).collect() + } + + pub async fn create_sandbox(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { + self.validate_sandbox_create(sandbox)?; + + if sandbox + .spec + .as_ref() + .map_or(true, |s| s.sandbox_token.is_empty()) + { + return Err(tonic::Status::invalid_argument("sandbox_token is required")); + } + + let sandbox_id = sandbox.id.clone(); + let sandbox_name = sandbox.name.clone(); + + { + let mut registry = self.registry.lock().await; + if registry.contains_key(&sandbox_id) { + return Err(tonic::Status::already_exists(format!( + "sandbox {sandbox_name} already exists" + ))); + } + let initial = make_sandbox_with_condition( + sandbox, + &DriverCondition { + r#type: "Ready".into(), + status: "False".into(), + reason: "Starting".into(), + message: "MXC lifecycle starting".into(), + last_transition_time: String::new(), + }, + false, + ); + let _ = self.watch_tx.send(sandbox_event(initial.clone())); + registry.insert( + sandbox_id.clone(), + SandboxEntry { + sandbox: initial, + iso_sandbox_id: None, + phase_state: PhaseState::Starting, + exec_child: None, + }, + ); + } + + let invoker = self.invoker.clone(); + let config = self.config.clone(); + let registry = self.registry.clone(); + let watch_tx = self.watch_tx.clone(); + let policy_mapper = self.policy_mapper.clone(); + let sandbox = sandbox.clone(); + + tokio::spawn(async move { + run_lifecycle(invoker, config, policy_mapper, registry, watch_tx, sandbox).await; + }); + + Ok(()) + } + + pub async fn stop_sandbox(&self, sandbox_name: &str) -> Result<(), tonic::Status> { + let (iso_id, sandbox_id) = { + let registry = self.registry.lock().await; + let entry = registry + .values() + .find(|e| e.sandbox.name == sandbox_name) + .ok_or_else(|| { + tonic::Status::not_found(format!("sandbox {sandbox_name} not found")) + })?; + (entry.iso_sandbox_id.clone(), entry.sandbox.id.clone()) + }; + + if let Some(ref iso_id) = iso_id { + if let Err(e) = self.invoker.stop(iso_id).await { + warn!(sandbox = %sandbox_name, error = %e, "wxc-exec stop failed"); + } + } + + let watch_tx = self.watch_tx.clone(); + let mut registry = self.registry.lock().await; + if let Some(entry) = registry.get_mut(&sandbox_id) { + entry.phase_state = PhaseState::Stopped; + entry.sandbox = make_sandbox_with_condition( + &entry.sandbox, + &DriverCondition { + r#type: "Ready".into(), + status: "False".into(), + reason: "Stopped".into(), + message: "MXC sandbox stopped".into(), + last_transition_time: String::new(), + }, + false, + ); + let snapshot = entry.sandbox.clone(); + drop(registry); + let _ = watch_tx.send(sandbox_event(snapshot)); + } + Ok(()) + } + + pub async fn delete_sandbox( + &self, + sandbox_id: &str, + sandbox_name: &str, + ) -> Result { + let iso_id = { + let registry = self.registry.lock().await; + registry + .get(sandbox_id) + .and_then(|e| e.iso_sandbox_id.clone()) + }; + + if let Some(iso_id) = iso_id { + let _ = self.invoker.stop(&iso_id).await; + if let Err(e) = self.invoker.deprovision(&iso_id).await { + warn!(sandbox = %sandbox_name, error = %e, "wxc-exec deprovision failed"); + } + } + + let mut registry = self.registry.lock().await; + if registry.remove(sandbox_id).is_some() { + let _ = self.watch_tx.send(deleted_event(sandbox_id.to_string())); + return Ok(true); + } + Ok(false) + } + + /// Returns a stream of watch events. + /// + /// First emits a snapshot of all current sandboxes, then forwards live + /// events from the broadcast channel. + pub async fn watch_sandboxes(&self) -> WatchStream { + let (tx, rx) = mpsc::channel::>(256); + + // Send initial snapshots before subscribing so we don't miss live events. + let snapshots: Vec = { + let registry = self.registry.lock().await; + registry.values().map(|e| e.sandbox.clone()).collect() + }; + let mut broadcast_rx = self.watch_tx.subscribe(); + + let tx_clone = tx.clone(); + tokio::spawn(async move { + // Deliver initial snapshots. + for sb in snapshots { + if tx_clone.send(Ok(sandbox_event(sb))).await.is_err() { + return; + } + } + // Forward live events. + loop { + match broadcast_rx.recv().await { + Ok(event) => { + if tx_clone.send(Ok(event)).await.is_err() { + break; + } + } + Err(broadcast::error::RecvError::Lagged(_)) => { + // Drop lagged events — the gateway re-syncs via Get/List. + continue; + } + Err(broadcast::error::RecvError::Closed) => break, + } + } + }); + + Box::pin(ReceiverStream::new(rx)) + } +} + +// ── Lifecycle task ──────────────────────────────────────────────────────────── + +async fn run_lifecycle( + invoker: WxcExecInvoker, + config: MxcComputeConfig, + policy_mapper: Arc, + registry: Arc>>, + watch_tx: Arc>, + sandbox: DriverSandbox, +) { + let sandbox_id = sandbox.id.clone(); + let sandbox_name = sandbox.name.clone(); + + // 1. Map policy → MXC filesystem config. + let map_ctx = MapCtx { + sandbox_id: sandbox_id.clone(), + share_dir: if config.share_dir.is_empty() { + None + } else { + Some(config.share_dir.clone()) + }, + }; + let mapped = match policy_mapper.map(&map_ctx) { + Ok(m) => m, + Err(e) => { + set_failed(®istry, &watch_tx, &sandbox, &sandbox_id, &e.to_string()).await; + return; + } + }; + + // 2. Provision. + let filesystem = MxcFilesystem { + readwrite_paths: mapped.readwrite_paths, + readonly_paths: mapped.readonly_paths, + }; + let iso_sandbox_id = match invoker + .provision(&config.default_configuration_id, filesystem) + .await + { + Ok(id) => id, + Err(e) => { + set_failed(®istry, &watch_tx, &sandbox, &sandbox_id, &e.to_string()).await; + return; + } + }; + info!(sandbox = %sandbox_name, iso_id = %iso_sandbox_id, "MXC provisioned"); + + { + let mut reg = registry.lock().await; + if let Some(entry) = reg.get_mut(&sandbox_id) { + entry.iso_sandbox_id = Some(iso_sandbox_id.clone()); + } + } + + // 3. Start. + if let Err(e) = invoker.start(&iso_sandbox_id).await { + set_failed(®istry, &watch_tx, &sandbox, &sandbox_id, &e.to_string()).await; + return; + } + info!(sandbox = %sandbox_name, "MXC started"); + + // 4. Exec agent command — spawn (don't await). + let command_line = config.agent_command.join(" "); + let cwd = if config.agent_cwd.is_empty() { + config.share_dir.clone() + } else { + config.agent_cwd.clone() + }; + let process = MxcProcess { + command_line: command_line.clone(), + cwd, + env: Vec::new(), + timeout: 0, + }; + let child = match invoker.spawn_exec(&iso_sandbox_id, process).await { + Ok(c) => c, + Err(e) => { + set_failed(®istry, &watch_tx, &sandbox, &sandbox_id, &e.to_string()).await; + return; + } + }; + info!(sandbox = %sandbox_name, command = %command_line, "MXC agent exec launched"); + + // 5. Self-report Ready=True. + let ready_sandbox = make_sandbox_with_condition( + &sandbox, + &DriverCondition { + r#type: "Ready".into(), + status: "True".into(), + reason: "AgentRunning".into(), + message: format!("Agent exec launched: {command_line}"), + last_transition_time: String::new(), + }, + false, + ); + { + let mut reg = registry.lock().await; + if let Some(entry) = reg.get_mut(&sandbox_id) { + entry.sandbox = ready_sandbox.clone(); + entry.phase_state = PhaseState::Running; + entry.exec_child = Some(child); + } + } + let _ = watch_tx.send(sandbox_event(ready_sandbox)); + + // 6. Monitor exec completion in background. + let registry2 = registry.clone(); + let watch_tx2 = watch_tx.clone(); + let sandbox2 = sandbox.clone(); + let sandbox_id2 = sandbox_id.clone(); + tokio::spawn(async move { + monitor_exec(registry2, watch_tx2, sandbox2, sandbox_id2).await; + }); +} + +async fn monitor_exec( + registry: Arc>>, + watch_tx: Arc>, + sandbox: DriverSandbox, + sandbox_id: String, +) { + let child = { + let mut reg = registry.lock().await; + reg.get_mut(&sandbox_id).and_then(|e| e.exec_child.take()) + }; + let Some(mut child) = child else { + return; + }; + + match child.wait().await { + Ok(status) if status.success() => { + info!(sandbox = %sandbox.name, "MXC agent exec completed successfully"); + let done = make_sandbox_with_condition( + &sandbox, + &DriverCondition { + r#type: "Ready".into(), + status: "False".into(), + reason: "ExecCompleted".into(), + message: "Agent exec finished with exit code 0".into(), + last_transition_time: String::new(), + }, + false, + ); + let mut reg = registry.lock().await; + if let Some(entry) = reg.get_mut(&sandbox_id) { + entry.sandbox = done.clone(); + entry.phase_state = PhaseState::Stopped; + } + drop(reg); + let _ = watch_tx.send(sandbox_event(done)); + } + Ok(status) => { + let code = status.code().unwrap_or(-1); + warn!(sandbox = %sandbox.name, exit_code = code, "MXC agent exec exited non-zero"); + let _ = watch_tx.send(platform_event( + sandbox_id.clone(), + "AgentExecFailed", + format!("agent exited with code {code}; possible out-of-policy write"), + )); + let failed = make_sandbox_with_condition( + &sandbox, + &DriverCondition { + r#type: "Ready".into(), + status: "False".into(), + reason: "ExecFailed".into(), + message: format!("Agent exec exited {code}"), + last_transition_time: String::new(), + }, + false, + ); + let mut reg = registry.lock().await; + if let Some(entry) = reg.get_mut(&sandbox_id) { + entry.sandbox = failed.clone(); + entry.phase_state = PhaseState::Failed(format!("exit code {code}")); + } + drop(reg); + let _ = watch_tx.send(sandbox_event(failed)); + } + Err(e) => { + warn!(sandbox = %sandbox.name, error = %e, "MXC agent exec wait error"); + } + } +} + +async fn set_failed( + registry: &Arc>>, + watch_tx: &Arc>, + sandbox: &DriverSandbox, + sandbox_id: &str, + message: &str, +) { + warn!(sandbox = %sandbox.name, error = %message, "MXC lifecycle failed"); + let failed = make_sandbox_with_condition( + sandbox, + &DriverCondition { + r#type: "Ready".into(), + status: "False".into(), + reason: "ProvisionFailed".into(), + message: message.to_string(), + last_transition_time: String::new(), + }, + false, + ); + let mut reg = registry.lock().await; + if let Some(entry) = reg.get_mut(sandbox_id) { + entry.sandbox = failed.clone(); + entry.phase_state = PhaseState::Failed(message.to_string()); + } + drop(reg); + let _ = watch_tx.send(sandbox_event(failed)); +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +fn make_sandbox_with_condition( + base: &DriverSandbox, + condition: &DriverCondition, + deleting: bool, +) -> DriverSandbox { + DriverSandbox { + id: base.id.clone(), + name: base.name.clone(), + namespace: base.namespace.clone(), + spec: base.spec.clone(), + status: Some(DriverSandboxStatus { + sandbox_name: base.name.clone(), + instance_id: String::new(), + agent_fd: String::new(), + sandbox_fd: String::new(), + conditions: vec![condition.clone()], + deleting, + }), + } +} diff --git a/crates/openshell-driver-mxc/src/grpc.rs b/crates/openshell-driver-mxc/src/grpc.rs new file mode 100644 index 0000000000..ed9db96f6e --- /dev/null +++ b/crates/openshell-driver-mxc/src/grpc.rs @@ -0,0 +1,142 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Thin tonic adapter: delegates to `MxcComputeBackend` and maps errors to +//! gRPC `Status`. + +#![allow(clippy::result_large_err)] + +use crate::driver::MxcComputeBackend; +use futures::{Stream, StreamExt}; +use openshell_core::proto::compute::v1::{ + CreateSandboxRequest, CreateSandboxResponse, + DeleteSandboxRequest, DeleteSandboxResponse, + GetCapabilitiesRequest, GetCapabilitiesResponse, + GetSandboxRequest, GetSandboxResponse, + ListSandboxesRequest, ListSandboxesResponse, + StopSandboxRequest, StopSandboxResponse, + ValidateSandboxCreateRequest, ValidateSandboxCreateResponse, + WatchSandboxesEvent, + WatchSandboxesRequest, + compute_driver_server::ComputeDriver, +}; +use std::pin::Pin; +use tonic::{Request, Response, Status}; + +#[derive(Debug)] +pub struct ComputeDriverService { + backend: MxcComputeBackend, +} + +impl ComputeDriverService { + pub fn new(backend: MxcComputeBackend) -> Self { + Self { backend } + } +} + +#[tonic::async_trait] +impl ComputeDriver for ComputeDriverService { + async fn get_capabilities( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(self.backend.capabilities())) + } + + async fn validate_sandbox_create( + &self, + request: Request, + ) -> Result, Status> { + let sandbox = request + .into_inner() + .sandbox + .ok_or_else(|| Status::invalid_argument("sandbox is required"))?; + self.backend.validate_sandbox_create(&sandbox)?; + Ok(Response::new(ValidateSandboxCreateResponse {})) + } + + async fn get_sandbox( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + if req.sandbox_name.is_empty() { + return Err(Status::invalid_argument("sandbox_name is required")); + } + let sandbox = self + .backend + .get_sandbox(&req.sandbox_name) + .await + .ok_or_else(|| Status::not_found(format!("sandbox {} not found", req.sandbox_name)))?; + if !req.sandbox_id.is_empty() && req.sandbox_id != sandbox.id { + return Err(Status::failed_precondition( + "sandbox_id did not match the fetched sandbox", + )); + } + Ok(Response::new(GetSandboxResponse { + sandbox: Some(sandbox), + })) + } + + async fn list_sandboxes( + &self, + _request: Request, + ) -> Result, Status> { + let sandboxes = self.backend.list_sandboxes().await; + Ok(Response::new(ListSandboxesResponse { sandboxes })) + } + + async fn create_sandbox( + &self, + request: Request, + ) -> Result, Status> { + let sandbox = request + .into_inner() + .sandbox + .ok_or_else(|| Status::invalid_argument("sandbox is required"))?; + self.backend.create_sandbox(&sandbox).await?; + Ok(Response::new(CreateSandboxResponse {})) + } + + async fn stop_sandbox( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + if req.sandbox_name.is_empty() { + return Err(Status::invalid_argument("sandbox_name is required")); + } + self.backend.stop_sandbox(&req.sandbox_name).await?; + Ok(Response::new(StopSandboxResponse {})) + } + + async fn delete_sandbox( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + if req.sandbox_id.is_empty() { + return Err(Status::invalid_argument("sandbox_id is required")); + } + if req.sandbox_name.is_empty() { + return Err(Status::invalid_argument("sandbox_name is required")); + } + let deleted = self + .backend + .delete_sandbox(&req.sandbox_id, &req.sandbox_name) + .await?; + Ok(Response::new(DeleteSandboxResponse { deleted })) + } + + type WatchSandboxesStream = + Pin> + Send + 'static>>; + + async fn watch_sandboxes( + &self, + _request: Request, + ) -> Result, Status> { + let stream = self.backend.watch_sandboxes().await; + let mapped = stream.map(|item| item.map_err(|e| Status::internal(e.to_string()))); + Ok(Response::new(Box::pin(mapped))) + } +} diff --git a/crates/openshell-driver-mxc/src/lib.rs b/crates/openshell-driver-mxc/src/lib.rs new file mode 100644 index 0000000000..bd6bb59bcc --- /dev/null +++ b/crates/openshell-driver-mxc/src/lib.rs @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! OpenShell MXC compute driver. +//! +//! Implements the gateway's `ComputeDriver` gRPC contract backed by Microsoft +//! MXC (`wxc-exec`) on Windows. The driver is **in-process**, runs the agent +//! directly (exec-in-driver), and self-reports `Ready` — there is no +//! in-sandbox supervisor, no host-side surrogate, and no `ConnectSupervisor` +//! relay. +//! +//! This crate compiles to an **empty stub** on non-Windows targets so the +//! Linux build stays green. All implementation code is gated on +//! `#[cfg(target_os = "windows")]`. + +#![allow(clippy::result_large_err)] + +#[cfg(target_os = "windows")] +mod driver; +#[cfg(target_os = "windows")] +mod grpc; +#[cfg(target_os = "windows")] +mod mxc; +#[cfg(target_os = "windows")] +mod policy; + +#[cfg(target_os = "windows")] +pub use driver::{MxcComputeBackend, MxcComputeConfig}; +#[cfg(target_os = "windows")] +pub use grpc::ComputeDriverService; diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs new file mode 100644 index 0000000000..ce17ec7572 --- /dev/null +++ b/crates/openshell-driver-mxc/src/mxc.rs @@ -0,0 +1,447 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! `wxc-exec` invoker and MXC request/response types. +//! +//! Builds state-aware MXC config JSON, base64-encodes it, runs `wxc-exec`, +//! and parses the response envelope. The exec phase is special: its stdout is +//! live process output (not JSON) and its exit code is the agent exit code. + +use base64::Engine as _; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use thiserror::Error; +use tokio::process::Command; +use tracing::debug; + +/// MXC config schema version. +pub const MXC_SCHEMA_VERSION: &str = "0.6.0-alpha"; + +/// Default `configurationId` for isolation session. Never use `"small"` (known OS bug). +pub const DEFAULT_CONFIGURATION_ID: &str = "composable"; + +// ── Request types ───────────────────────────────────────────────────────────── + +/// Filesystem shares for the sandbox (MXC provision-time only). +#[derive(Debug, Default, Serialize)] +pub struct MxcFilesystem { + #[serde(rename = "readwritePaths", skip_serializing_if = "Vec::is_empty")] + pub readwrite_paths: Vec, + #[serde(rename = "readonlyPaths", skip_serializing_if = "Vec::is_empty")] + pub readonly_paths: Vec, +} + +/// Process config for the exec phase. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MxcProcess { + pub command_line: String, + pub cwd: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub env: Vec, + /// 0 = no timeout (long-lived agent). + pub timeout: u64, +} + +// ── Response envelope ───────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +pub struct ProvisionResult { + #[serde(rename = "sandboxId")] + pub sandbox_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum MxcEnvelope { + Ok { + #[allow(dead_code)] + result: serde_json::Value, + }, + Err { error: MxcErrorBody }, +} + +#[derive(Debug, Deserialize)] +pub struct MxcErrorBody { + pub code: String, + pub message: String, +} + +#[derive(Debug, Deserialize)] +pub struct ProvisionEnvelope { + pub result: Option, + pub error: Option, +} + +// ── Errors ──────────────────────────────────────────────────────────────────── + +#[derive(Debug, Error)] +pub enum InvokerError { + #[error("wxc-exec spawn failed: {0}")] + Spawn(#[from] std::io::Error), + #[error("wxc-exec config serialization failed: {0}")] + Serialize(#[from] serde_json::Error), + #[error("wxc-exec envelope parse failed (stdout={stdout:?}): {source}")] + Parse { + stdout: String, + source: serde_json::Error, + }, + #[error("wxc-exec process failed with no envelope (exit={exit_code}, stderr={stderr:?})")] + NoEnvelope { exit_code: i32, stderr: String }, + #[error("MXC error [{code}]: {message}")] + Mxc { code: String, message: String }, + /// Exec phase returned a non-zero exit code (the agent's own exit status). + /// Surfaced through the watch stream rather than as a gRPC error. + #[allow(dead_code)] + #[error("wxc-exec exec phase exited with code {0}")] + ExecNonZero(i32), +} + +impl InvokerError { + #[allow(dead_code)] + pub fn to_tonic_status(&self) -> tonic::Status { + match self { + Self::Mxc { code, message } => match code.as_str() { + "malformed_request" | "unsupported_phase" => { + tonic::Status::internal(format!("driver bug: {message}")) + } + "unsupported_containment" | "not_provisioned" | "not_started" + | "already_started" | "already_stopped" => { + tonic::Status::failed_precondition(message.clone()) + } + "malformed_id" | "stale_id" => tonic::Status::not_found(message.clone()), + "policy_validation" => tonic::Status::invalid_argument(message.clone()), + "backend_unavailable" => tonic::Status::unavailable(message.clone()), + _ => tonic::Status::internal(message.clone()), + }, + Self::Spawn(e) => tonic::Status::internal(format!("wxc-exec spawn: {e}")), + Self::Serialize(e) => tonic::Status::internal(format!("config serialize: {e}")), + Self::Parse { .. } | Self::NoEnvelope { .. } => { + tonic::Status::internal(self.to_string()) + } + Self::ExecNonZero(code) => { + tonic::Status::internal(format!("agent exited with code {code}")) + } + } + } +} + +// ── Invoker ─────────────────────────────────────────────────────────────────── + +/// Wraps `wxc-exec` invocations for the MXC state-aware lifecycle. +#[derive(Debug, Clone)] +pub struct WxcExecInvoker { + exec_path: PathBuf, + debug: bool, +} + +impl WxcExecInvoker { + pub fn new(exec_path: impl Into, debug: bool) -> Self { + Self { + exec_path: exec_path.into(), + debug, + } + } + + /// Encode `config` as base64 and invoke wxc-exec, returning the parsed envelope. + /// Use this for all **non-exec** phases (provision/start/stop/deprovision). + pub async fn run_phase(&self, config: &serde_json::Value) -> Result<(), InvokerError> { + let json = serde_json::to_string(config)?; + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + + let mut cmd = Command::new(&self.exec_path); + cmd.arg("--config-base64").arg(&b64).arg("--experimental"); + if self.debug { + cmd.arg("--debug"); + } + + debug!(config = %json, "wxc-exec phase"); + let output = cmd.output().await?; + + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + + if !output.status.success() { + if let Ok(env) = serde_json::from_str::(&stdout) { + if let MxcEnvelope::Err { error } = env { + return Err(InvokerError::Mxc { + code: error.code, + message: error.message, + }); + } + } + let code = output.status.code().unwrap_or(-1); + return Err(InvokerError::NoEnvelope { + exit_code: code, + stderr, + }); + } + + // Success — parse envelope to surface any embedded error field. + match serde_json::from_str::(&stdout) { + Ok(MxcEnvelope::Err { error }) => Err(InvokerError::Mxc { + code: error.code, + message: error.message, + }), + Ok(MxcEnvelope::Ok { .. }) => Ok(()), + Err(_) if stdout.trim().is_empty() => { + // Some phases return empty stdout on success. + Ok(()) + } + Err(e) => Err(InvokerError::Parse { stdout, source: e }), + } + } + + /// Run the provision phase and return the `sandboxId` from the response. + pub async fn provision( + &self, + configuration_id: &str, + filesystem: MxcFilesystem, + ) -> Result { + let config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "phase": "provision", + "containment": "isolation_session", + "filesystem": { + "readwritePaths": filesystem.readwrite_paths, + "readonlyPaths": filesystem.readonly_paths, + }, + "experimental": { + "isolation_session": { + "configurationId": configuration_id, + "provision": {} + } + } + }); + + let json = serde_json::to_string(&config)?; + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + + let mut cmd = Command::new(&self.exec_path); + cmd.arg("--config-base64").arg(&b64).arg("--experimental"); + if self.debug { + cmd.arg("--debug"); + } + + debug!(config = %json, "wxc-exec provision"); + let output = cmd.output().await?; + + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + + if !output.status.success() { + let code = output.status.code().unwrap_or(-1); + if let Ok(env) = serde_json::from_str::(&stdout) { + if let Some(err) = env.error { + return Err(InvokerError::Mxc { + code: err.code, + message: err.message, + }); + } + } + return Err(InvokerError::NoEnvelope { + exit_code: code, + stderr, + }); + } + + let env: ProvisionEnvelope = serde_json::from_str(&stdout).map_err(|e| { + InvokerError::Parse { + stdout: stdout.clone(), + source: e, + } + })?; + + if let Some(err) = env.error { + return Err(InvokerError::Mxc { + code: err.code, + message: err.message, + }); + } + + env.result.map(|r| r.sandbox_id).ok_or_else(|| { + InvokerError::NoEnvelope { + exit_code: 0, + stderr: "provision result missing sandboxId".to_string(), + } + }) + } + + /// Run the start phase for an already-provisioned sandbox. + pub async fn start(&self, iso_sandbox_id: &str) -> Result<(), InvokerError> { + let config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "phase": "start", + "sandboxId": iso_sandbox_id, + "experimental": { + "isolation_session": { + "start": {} + } + } + }); + self.run_phase(&config).await + } + + /// Spawn the exec phase (agent command). Returns the child process handle. + /// **Stdout is raw agent output, not a JSON envelope. Exit code == agent exit code.** + pub async fn spawn_exec( + &self, + iso_sandbox_id: &str, + process: MxcProcess, + ) -> Result { + let config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "phase": "exec", + "sandboxId": iso_sandbox_id, + "process": { + "commandLine": process.command_line, + "cwd": process.cwd, + "env": process.env, + "timeout": process.timeout, + } + }); + + let json = serde_json::to_string(&config)?; + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + + let mut cmd = Command::new(&self.exec_path); + cmd.arg("--config-base64") + .arg(&b64) + .arg("--experimental") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + if self.debug { + cmd.arg("--debug"); + } + + debug!(sandbox_id = %iso_sandbox_id, command = %process.command_line, "wxc-exec exec spawn"); + let child = cmd.spawn()?; + Ok(child) + } + + /// Run the stop phase. + pub async fn stop(&self, iso_sandbox_id: &str) -> Result<(), InvokerError> { + let config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "phase": "stop", + "sandboxId": iso_sandbox_id, + "experimental": { + "isolation_session": { + "stop": {} + } + } + }); + self.run_phase(&config).await + } + + /// Run the deprovision phase. + pub async fn deprovision(&self, iso_sandbox_id: &str) -> Result<(), InvokerError> { + let config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "phase": "deprovision", + "sandboxId": iso_sandbox_id, + "experimental": { + "isolation_session": { + "deprovision": {} + } + } + }); + self.run_phase(&config).await + } +} + +// ── Tests (pure serde — compile and run cross-platform) ────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provision_envelope_parse_success() { + let json = r#"{"result":{"sandboxId":"iso:wxc-abc123","metadata":{}}}"#; + let env: ProvisionEnvelope = serde_json::from_str(json).unwrap(); + assert_eq!(env.result.unwrap().sandbox_id, "iso:wxc-abc123"); + assert!(env.error.is_none()); + } + + #[test] + fn provision_envelope_parse_error() { + let json = r#"{"error":{"code":"backend_unavailable","message":"IsoSessionApp.dll missing"}}"#; + let env: ProvisionEnvelope = serde_json::from_str(json).unwrap(); + assert!(env.result.is_none()); + let err = env.error.unwrap(); + assert_eq!(err.code, "backend_unavailable"); + } + + #[test] + fn mxc_envelope_success_variant() { + let json = r#"{"result":{}}"#; + let env: MxcEnvelope = serde_json::from_str(json).unwrap(); + assert!(matches!(env, MxcEnvelope::Ok { .. })); + } + + #[test] + fn mxc_envelope_error_variant() { + let json = r#"{"error":{"code":"not_provisioned","message":"call provision first"}}"#; + let env: MxcEnvelope = serde_json::from_str(json).unwrap(); + assert!(matches!(env, MxcEnvelope::Err { .. })); + } + + #[test] + fn provision_config_json_shape() { + // Verify the JSON we send wxc-exec has the expected shape. + let config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "phase": "provision", + "containment": "isolation_session", + "filesystem": { + "readwritePaths": ["C:\\work\\demo"], + "readonlyPaths": [], + }, + "experimental": { + "isolation_session": { + "configurationId": DEFAULT_CONFIGURATION_ID, + "provision": {} + } + } + }); + assert_eq!(config["phase"], "provision"); + assert_eq!(config["containment"], "isolation_session"); + assert_eq!( + config["experimental"]["isolation_session"]["configurationId"], + "composable" + ); + assert_eq!(config["filesystem"]["readwritePaths"][0], "C:\\work\\demo"); + } + + #[test] + fn invoker_error_maps_backend_unavailable_to_unavailable() { + let err = InvokerError::Mxc { + code: "backend_unavailable".into(), + message: "missing DLL".into(), + }; + let status = err.to_tonic_status(); + assert_eq!(status.code(), tonic::Code::Unavailable); + } + + #[test] + fn invoker_error_maps_policy_validation_to_invalid_argument() { + let err = InvokerError::Mxc { + code: "policy_validation".into(), + message: "path denied".into(), + }; + let status = err.to_tonic_status(); + assert_eq!(status.code(), tonic::Code::InvalidArgument); + } + + #[test] + fn invoker_error_maps_stale_id_to_not_found() { + let err = InvokerError::Mxc { + code: "stale_id".into(), + message: "session expired".into(), + }; + let status = err.to_tonic_status(); + assert_eq!(status.code(), tonic::Code::NotFound); + } +} diff --git a/crates/openshell-driver-mxc/src/policy.rs b/crates/openshell-driver-mxc/src/policy.rs new file mode 100644 index 0000000000..efa6cc4d11 --- /dev/null +++ b/crates/openshell-driver-mxc/src/policy.rs @@ -0,0 +1,123 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! PolicyMapper seam: `SandboxPolicy` → MXC `ContainerConfig` fragment. +//! +//! This skill does **not** write the actual policy mapping — that is +//! Giedrius's Rust mapper crate. This module defines the trait seam and ships +//! a minimal `StubPolicyMapper` that is only sufficient to compile and run +//! unit tests. Wire Giedrius's crate as the primary binding once it lands. +//! +//! **Rule: never silently drop policy.** Unmappable rules must surface as +//! `MapError::Unsupported` and be rejected in `ValidateSandboxCreate`. + +use thiserror::Error; + +/// The MXC config fragment derived from a `SandboxPolicy`. +/// +/// Carries filesystem share lists for the MXC provision phase. +/// Future fields: `network_proxy` (Stage 2 egress skill). +#[derive(Debug, Default, Clone)] +pub struct MappedConfig { + /// Paths granted read-write access inside the sandbox. + pub readwrite_paths: Vec, + /// Paths granted read-only access inside the sandbox. + pub readonly_paths: Vec, +} + +/// Context passed to the mapper alongside the policy. +#[derive(Debug)] +pub struct MapCtx { + /// Sandbox ID (gateway-assigned). Used by the real PolicyMapper to correlate + /// policy lookups; unused by the stub. + #[allow(dead_code)] + pub sandbox_id: String, + /// Host share directory for the demo positive proof. + pub share_dir: Option, +} + +/// A policy rule that the active mapper cannot enforce. +#[derive(Debug, Clone)] +pub struct LossItem { + pub rule_kind: String, + pub detail: String, +} + +/// Error returned when policy translation fails or is incomplete. +/// +/// Variants are constructed by the real `PolicyMapper` implementation (Giedrius's +/// crate). The `StubPolicyMapper` does not construct them — hence the `dead_code` +/// allow below; they are part of the public seam contract. +#[derive(Debug, Error)] +#[allow(dead_code)] +pub enum MapError { + #[error("policy rule(s) cannot be enforced by the MXC driver: {}", format_loss(.0))] + Unsupported(Vec), + #[error("policy mapper internal error: {0}")] + Internal(String), +} + +fn format_loss(items: &[LossItem]) -> String { + items + .iter() + .map(|i| format!("{}: {}", i.rule_kind, i.detail)) + .collect::>() + .join("; ") +} + +/// Translates an OpenShell `SandboxPolicy` into an MXC `ContainerConfig` +/// fragment, returning a loss report of anything unrepresentable. +/// +/// The implementing crate (Giedrius's mapper) is bound behind this trait. +/// The `StubPolicyMapper` ships as a compile-only fallback. +pub trait PolicyMapper: Send + Sync { + fn map(&self, ctx: &MapCtx) -> Result; +} + +// ── Stub implementation ─────────────────────────────────────────────────────── + +/// Compile-only stub that applies only the demo's filesystem grant. +/// +/// Maps `ctx.share_dir` as a read-write path. Rejects any other policy rule. +/// **Not sufficient for a live agent run** — replace with Giedrius's crate. +pub struct StubPolicyMapper; + +impl PolicyMapper for StubPolicyMapper { + fn map(&self, ctx: &MapCtx) -> Result { + let mut config = MappedConfig::default(); + if let Some(ref dir) = ctx.share_dir { + config.readwrite_paths.push(dir.clone()); + } + Ok(config) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn demo_ctx(share_dir: Option<&str>) -> MapCtx { + MapCtx { + sandbox_id: "sb-test".into(), + share_dir: share_dir.map(str::to_string), + } + } + + #[test] + fn stub_maps_share_dir_as_readwrite() { + let mapper = StubPolicyMapper; + let ctx = demo_ctx(Some("C:\\work\\demo")); + let config = mapper.map(&ctx).unwrap(); + assert_eq!(config.readwrite_paths, vec!["C:\\work\\demo"]); + assert!(config.readonly_paths.is_empty()); + } + + #[test] + fn stub_produces_empty_config_without_share_dir() { + let mapper = StubPolicyMapper; + let ctx = demo_ctx(None); + let config = mapper.map(&ctx).unwrap(); + assert!(config.readwrite_paths.is_empty()); + assert!(config.readonly_paths.is_empty()); + } +} From 6f006495520d87f5b397c0fc2d8f66786fad059e Mon Sep 17 00:00:00 2001 From: Jamie King Date: Mon, 8 Jun 2026 21:43:37 -0600 Subject: [PATCH 02/19] wip(mxc): checkpoint hung-agent work (recon, policy_map embed, A1 wiring, demo artifacts) Safety checkpoint of uncommitted work from the background agent run that stalled mid-Step-7. Includes: mxc-driver-recon.md (Step 0.5), policy_map.rs (~876L embedded mapper), A1 policy-threading edits across driver.rs/policy.rs/mxc.rs/compute/mod.rs, and examples/ (demo.yaml + mxc-gateway.toml). Not yet verified to compile end-to-end; to be reorganized into the skill's Step 11 commit sequence. (cherry picked from commit 38e42c03870be3d10e984a54f17a3b61122ff510) Signed-off-by: Jamie King --- crates/openshell-driver-mxc/Cargo.toml | 6 + .../openshell-driver-mxc/examples/demo.yaml | 25 + .../examples/mxc-gateway.toml | 39 + crates/openshell-driver-mxc/src/driver.rs | 267 ++++- crates/openshell-driver-mxc/src/lib.rs | 4 + crates/openshell-driver-mxc/src/mxc.rs | 102 ++ crates/openshell-driver-mxc/src/policy.rs | 287 +++++- crates/openshell-driver-mxc/src/policy_map.rs | 958 ++++++++++++++++++ 8 files changed, 1651 insertions(+), 37 deletions(-) create mode 100644 crates/openshell-driver-mxc/examples/demo.yaml create mode 100644 crates/openshell-driver-mxc/examples/mxc-gateway.toml create mode 100644 crates/openshell-driver-mxc/src/policy_map.rs diff --git a/crates/openshell-driver-mxc/Cargo.toml b/crates/openshell-driver-mxc/Cargo.toml index c7d2b91eaf..902e1381ab 100644 --- a/crates/openshell-driver-mxc/Cargo.toml +++ b/crates/openshell-driver-mxc/Cargo.toml @@ -21,6 +21,10 @@ futures = { workspace = true } tokio-stream = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +# serde_yaml is not a workspace dependency, but 0.9.34 is already resolved in +# Cargo.lock (transitive). Used by the embedded `policy_map` module to consume +# the OpenShell policy in the shape Giedrius's mapper expects. +serde_yaml = "0.9" base64 = { workspace = true } tracing = { workspace = true } thiserror = { workspace = true } @@ -28,6 +32,8 @@ uuid = { workspace = true } [dev-dependencies] tokio = { workspace = true } +# tempfile is not a workspace dependency; 3.27 is already resolved in Cargo.lock. +tempfile = "3" [lints] workspace = true diff --git a/crates/openshell-driver-mxc/examples/demo.yaml b/crates/openshell-driver-mxc/examples/demo.yaml new file mode 100644 index 0000000000..0aedd0da68 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/demo.yaml @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# demo.yaml — June 15 MXC filesystem-policy proof. +# +# Minimal filesystem policy granting the shared host folder read-write; +# everything else is implicitly denied (default-deny). The granted path MUST +# match `share_dir` / OPENSHELL_MXC_SHARE_DIR and the agent_command target. +# +# NOTE: the canonical OpenShell policy YAML key is `filesystem_policy` +# (parsed by the `openshell-policy` crate into SandboxPolicy.filesystem), NOT +# `filesystem`. The MXC driver's policy bridge then re-emits this under the +# `filesystem_policy` key the embedded mapper expects. +version: 1 + +filesystem_policy: + include_workdir: false + read_only: [] + read_write: + - "C:/work/openshell-mxc-demo" # = OPENSHELL_MXC_SHARE_DIR (host-visible share) + +# No landlock / process / network_policies for the demo. (Network policy on +# isolation_session is REJECTED by the driver — see the crate README. Adding a +# network_policies block here would make `sandbox create` fail with a precise +# invalid_argument naming the rule, never a silent drop.) diff --git a/crates/openshell-driver-mxc/examples/mxc-gateway.toml b/crates/openshell-driver-mxc/examples/mxc-gateway.toml new file mode 100644 index 0000000000..a074d353d2 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-gateway.toml @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# MXC gateway config for the June 15 demo. +# +# Pass to the gateway with `--config ` (or the gateway's config-file +# discovery). The `[openshell.drivers.mxc]` table is deserialized into +# `MxcComputeConfig`. +# +# Keep `share_dir`, `agent_cwd`, the agent_command target path, and demo.yaml's +# `filesystem_policy.read_write` entry IDENTICAL, or the positive proof will not +# line up. + +[openshell.drivers.mxc] +# Path to wxc-exec.exe — REQUIRED for live runs on the demo box. Leave commented +# for mock-mode smoke tests (set OPENSHELL_MXC_MOCK_WXC=1 instead). +# wxc_exec_path = "C:\\mxc\\wxc-exec.exe" + +# MXC configurationId for isolation session. Never use "small" (known OS bug). +default_configuration_id = "composable" + +# Host folder mapped read-write into the sandbox; where hello.txt appears. +share_dir = "C:/work/openshell-mxc-demo" + +# Working directory for the agent inside the sandbox (defaults to share_dir). +agent_cwd = "C:/work/openshell-mxc-demo" + +# The agent the driver execs (exec-in-driver). POSITIVE demo: writes hello.txt +# INSIDE the granted share. Swap target to an out-of-policy path (e.g. +# C:/Windows/Temp/hello.txt) to drive the NEGATIVE proof. +agent_command = [ + "powershell", + "-NoProfile", + "-Command", + "Set-Content -Path 'C:/work/openshell-mxc-demo/hello.txt' -Value 'hello from mxc'", +] + +# Enable --debug on wxc-exec invocations. +debug = false diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index e3f0c842b3..cdbfee55c3 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -5,7 +5,8 @@ //! and self-reported readiness. use crate::mxc::{MxcFilesystem, MxcProcess, WxcExecInvoker}; -use crate::policy::{MapCtx, PolicyMapper, StubPolicyMapper}; +use crate::policy::{EmbeddedPolicyMapper, MapCtx, PolicyMapper}; +use openshell_core::proto::SandboxPolicy; use openshell_core::proto::compute::v1::{ DriverCondition, DriverPlatformEvent, DriverSandbox, DriverSandboxStatus, GetCapabilitiesResponse, WatchSandboxesDeletedEvent, WatchSandboxesEvent, @@ -146,6 +147,13 @@ pub struct MxcComputeBackend { registry: Arc>>, watch_tx: Arc>, policy_mapper: Arc, + /// Out-of-band side channel for the `SandboxPolicy` (A1). The proto driver + /// contract has no `policy` field and there is no driver-side + /// `GetSandboxConfig`, so `ComputeRuntime::create_sandbox` stages the policy + /// here keyed by sandbox id (mirroring the `sandbox_token` injection), + /// immediately before dispatching to this backend's `create_sandbox`, which + /// removes/consumes it. + pending_policies: Arc>>, } impl std::fmt::Debug for MxcComputeBackend { @@ -165,10 +173,28 @@ impl MxcComputeBackend { config, registry: Arc::new(Mutex::new(HashMap::new())), watch_tx: Arc::new(watch_tx), - policy_mapper: Arc::new(StubPolicyMapper), + // Primary impl: the embedded mapper (Giedrius's logic vendored into + // `policy_map`). Swap for `StubPolicyMapper` only for scaffolding. + policy_mapper: Arc::new(EmbeddedPolicyMapper), + pending_policies: Arc::new(Mutex::new(HashMap::new())), } } + /// Returns a clone of the `pending_policies` side channel so the gateway's + /// `ComputeRuntime` can stage the typed `SandboxPolicy` by sandbox id right + /// before dispatching `create_sandbox` (A1 wiring). + pub fn policy_sink(&self) -> Arc>> { + self.pending_policies.clone() + } + + /// Test-only constructor wiring the in-process mock `wxc-exec` shim. + #[cfg(test)] + pub(crate) fn new_mocked(config: MxcComputeConfig) -> Self { + let mut backend = Self::new(config); + backend.invoker = WxcExecInvoker::mocked(&backend.config.wxc_exec_path); + backend + } + pub fn capabilities(&self) -> GetCapabilitiesResponse { openshell_core::driver_utils::build_capabilities_response( DRIVER_NAME, @@ -215,6 +241,13 @@ impl MxcComputeBackend { } pub async fn create_sandbox(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { + let sandbox_id = sandbox.id.clone(); + + // Consume the out-of-band policy staged by `ComputeRuntime::create_sandbox` + // (A1). Always remove — even on the early-return paths below — so nothing + // leaks if validation or the duplicate check rejects the create. + let policy = self.pending_policies.lock().await.remove(&sandbox_id); + self.validate_sandbox_create(sandbox)?; if sandbox @@ -225,7 +258,6 @@ impl MxcComputeBackend { return Err(tonic::Status::invalid_argument("sandbox_token is required")); } - let sandbox_id = sandbox.id.clone(); let sandbox_name = sandbox.name.clone(); { @@ -266,7 +298,8 @@ impl MxcComputeBackend { let sandbox = sandbox.clone(); tokio::spawn(async move { - run_lifecycle(invoker, config, policy_mapper, registry, watch_tx, sandbox).await; + run_lifecycle(invoker, config, policy_mapper, registry, watch_tx, sandbox, policy) + .await; }); Ok(()) @@ -384,6 +417,7 @@ impl MxcComputeBackend { // ── Lifecycle task ──────────────────────────────────────────────────────────── +#[allow(clippy::too_many_arguments)] async fn run_lifecycle( invoker: WxcExecInvoker, config: MxcComputeConfig, @@ -391,11 +425,12 @@ async fn run_lifecycle( registry: Arc>>, watch_tx: Arc>, sandbox: DriverSandbox, + policy: Option, ) { let sandbox_id = sandbox.id.clone(); let sandbox_name = sandbox.name.clone(); - // 1. Map policy → MXC filesystem config. + // 1. Map policy → MXC filesystem config (A1: policy is now threaded in). let map_ctx = MapCtx { sandbox_id: sandbox_id.clone(), share_dir: if config.share_dir.is_empty() { @@ -404,7 +439,7 @@ async fn run_lifecycle( Some(config.share_dir.clone()) }, }; - let mapped = match policy_mapper.map(&map_ctx) { + let mapped = match policy_mapper.map(policy.as_ref(), &map_ctx) { Ok(m) => m, Err(e) => { set_failed(®istry, &watch_tx, &sandbox, &sandbox_id, &e.to_string()).await; @@ -616,3 +651,223 @@ fn make_sandbox_with_condition( }), } } + +// ── Lifecycle + policy-proof tests (mock wxc-exec) ───────────────────────────── +// +// These drive the full create → provision → start → exec → self-report Ready +// flow against the in-process mock shim, proving the positive (in-policy write +// succeeds, Ready reached) and negative (out-of-policy write denied + denial +// event) paths WITHOUT the demo box. Windows-only (the crate is Windows-gated), +// run by the `windows:test:x64` mise lane. +#[cfg(test)] +mod lifecycle_tests { + use super::*; + use openshell_core::proto::compute::v1::DriverSandboxSpec; + use openshell_core::proto::{FilesystemPolicy, SandboxPolicy}; + use std::time::Duration; + + fn driver_sandbox(id: &str) -> DriverSandbox { + DriverSandbox { + id: id.to_string(), + name: id.to_string(), + namespace: String::new(), + spec: Some(DriverSandboxSpec { + sandbox_token: "test-token".into(), + ..Default::default() + }), + status: None, + } + } + + fn fs_policy(read_write: &[&str]) -> SandboxPolicy { + SandboxPolicy { + filesystem: Some(FilesystemPolicy { + include_workdir: false, + read_only: Vec::new(), + read_write: read_write.iter().map(|s| s.to_string()).collect(), + }), + ..Default::default() + } + } + + fn ready_condition(sb: &DriverSandbox) -> Option { + sb.status + .as_ref()? + .conditions + .iter() + .find(|c| c.r#type == "Ready") + .cloned() + } + + /// Poll the backend registry until the predicate matches or the deadline hits. + async fn wait_for(backend: &MxcComputeBackend, name: &str, mut pred: F) -> Option + where + F: FnMut(&DriverSandbox) -> bool, + { + for _ in 0..100 { + if let Some(sb) = backend.get_sandbox(name).await { + if pred(&sb) { + return Some(sb); + } + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + None + } + + fn demo_config(share_dir: &str, agent_command: Vec) -> MxcComputeConfig { + MxcComputeConfig { + wxc_exec_path: "wxc-exec.exe".into(), + default_configuration_id: "composable".into(), + agent_command, + agent_cwd: share_dir.into(), + share_dir: share_dir.into(), + debug: false, + } + } + + #[tokio::test] + async fn positive_in_policy_write_reaches_ready_and_materializes_file() { + let tmp = tempfile::tempdir().unwrap(); + let share = tmp.path().to_string_lossy().replace('\\', "/"); + let hello = format!("{share}/hello.txt"); + let cmd = vec![ + "powershell".into(), + "-NoProfile".into(), + "-Command".into(), + format!("Set-Content -LiteralPath {hello} -Value hi"), + ]; + let backend = MxcComputeBackend::new_mocked(demo_config(&share, cmd)); + + // Stage the policy via the A1 side channel (as ComputeRuntime would). + let sink = backend.policy_sink(); + sink.lock() + .await + .insert("sb-pos".into(), fs_policy(&[&share])); + + let sb = driver_sandbox("sb-pos"); + backend.create_sandbox(&sb).await.expect("create accepted"); + + // Self-reported Ready=True (no supervisor) once the agent exec launches. + let ready = wait_for(&backend, "sb-pos", |s| { + ready_condition(s).is_some_and(|c| c.status == "True" && c.reason == "AgentRunning") + }) + .await; + assert!(ready.is_some(), "sandbox should self-report Ready=True"); + + // Positive proof: the in-policy write materializes the host artifact. + let host_path = std::path::Path::new(tmp.path()).join("hello.txt"); + let mut found = false; + for _ in 0..100 { + if host_path.exists() { + found = true; + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + assert!(found, "hello.txt should appear in the granted share folder"); + } + + #[tokio::test] + async fn negative_out_of_policy_write_is_denied_with_event() { + let share_tmp = tempfile::tempdir().unwrap(); + let out_tmp = tempfile::tempdir().unwrap(); + let share = share_tmp.path().to_string_lossy().replace('\\', "/"); + let out_path = format!( + "{}/hello.txt", + out_tmp.path().to_string_lossy().replace('\\', "/") + ); + let cmd = vec![ + "powershell".into(), + "-NoProfile".into(), + "-Command".into(), + format!("Set-Content -LiteralPath {out_path} -Value hi"), + ]; + let backend = MxcComputeBackend::new_mocked(demo_config(&share, cmd)); + + // Subscribe to the watch stream BEFORE create so we catch the denial event. + let mut stream = backend.watch_sandboxes().await; + + let sink = backend.policy_sink(); + sink.lock() + .await + .insert("sb-neg".into(), fs_policy(&[&share])); + backend + .create_sandbox(&driver_sandbox("sb-neg")) + .await + .expect("create accepted"); + + // Collect events until we observe the AgentExecFailed platform event. + let mut saw_denial = false; + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + while tokio::time::Instant::now() < deadline { + match tokio::time::timeout(Duration::from_millis(500), stream.next()).await { + Ok(Some(Ok(ev))) => { + if let Some(watch_sandboxes_event::Payload::PlatformEvent(pe)) = ev.payload { + if pe + .event + .as_ref() + .is_some_and(|e| e.reason == "AgentExecFailed") + { + saw_denial = true; + break; + } + } + } + Ok(_) => break, + Err(_) => continue, + } + } + assert!(saw_denial, "expected an AgentExecFailed denial platform event"); + + // The out-of-policy artifact must NOT have been written by the mock. + let out_fs = std::path::Path::new(out_tmp.path()).join("hello.txt"); + assert!(!out_fs.exists(), "out-of-policy write must be denied"); + + // And the sandbox surfaces a terminal ExecFailed Ready=False condition. + let failed = wait_for(&backend, "sb-neg", |s| { + ready_condition(s).is_some_and(|c| c.status == "False" && c.reason == "ExecFailed") + }) + .await; + assert!(failed.is_some(), "sandbox should report ExecFailed"); + } + + #[tokio::test] + async fn unmappable_network_policy_fails_create_lifecycle() { + use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule}; + let tmp = tempfile::tempdir().unwrap(); + let share = tmp.path().to_string_lossy().replace('\\', "/"); + let cmd = vec!["cmd".into(), "/c".into(), "exit 0".into()]; + let backend = MxcComputeBackend::new_mocked(demo_config(&share, cmd)); + + let mut policy = fs_policy(&[&share]); + policy.network_policies.insert( + "api".into(), + NetworkPolicyRule { + name: "api".into(), + endpoints: vec![NetworkEndpoint { + host: "example.com".into(), + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + backend.policy_sink().lock().await.insert("sb-net".into(), policy); + backend + .create_sandbox(&driver_sandbox("sb-net")) + .await + .expect("create accepted (rejection happens in lifecycle)"); + + // Unmappable policy surfaces as a terminal create-time failure, never a + // silent drop. (ValidateSandboxCreate has no policy side channel, so the + // mapper rejection happens at map-time in run_lifecycle.) + let failed = wait_for(&backend, "sb-net", |s| { + ready_condition(s).is_some_and(|c| c.status == "False" && c.reason == "ProvisionFailed") + }) + .await; + assert!( + failed.is_some(), + "network policy on isolation_session must fail the create" + ); + } +} diff --git a/crates/openshell-driver-mxc/src/lib.rs b/crates/openshell-driver-mxc/src/lib.rs index bd6bb59bcc..46e63eeaf1 100644 --- a/crates/openshell-driver-mxc/src/lib.rs +++ b/crates/openshell-driver-mxc/src/lib.rs @@ -23,6 +23,10 @@ mod grpc; mod mxc; #[cfg(target_os = "windows")] mod policy; +// Embedded mapping logic vendored from Giedrius's mapper. Pure `serde`, NOT +// Windows-gated, so its parity tests run on Linux CI even though the rest of the +// driver is Windows-only. +mod policy_map; #[cfg(target_os = "windows")] pub use driver::{MxcComputeBackend, MxcComputeConfig}; diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs index ce17ec7572..1c14173b8b 100644 --- a/crates/openshell-driver-mxc/src/mxc.rs +++ b/crates/openshell-driver-mxc/src/mxc.rs @@ -9,7 +9,9 @@ use base64::Engine as _; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::path::PathBuf; +use std::sync::{Mutex, OnceLock}; use thiserror::Error; use tokio::process::Command; use tracing::debug; @@ -20,6 +22,31 @@ pub const MXC_SCHEMA_VERSION: &str = "0.6.0-alpha"; /// Default `configurationId` for isolation session. Never use `"small"` (known OS bug). pub const DEFAULT_CONFIGURATION_ID: &str = "composable"; +/// Environment flag selecting the in-process mock `wxc-exec` shim. When set to +/// `"1"`, the invoker does NOT spawn the real `wxc-exec.exe`; instead it emits +/// canned provision/start/stop/deprovision results and simulates AppContainer +/// filesystem-policy enforcement for the exec phase. This is what makes the +/// full create → Ready → policy-proof round trip runnable off the demo box. +pub const MOCK_ENV_VAR: &str = "OPENSHELL_MXC_MOCK_WXC"; + +fn mock_enabled() -> bool { + std::env::var(MOCK_ENV_VAR).map(|v| v == "1").unwrap_or(false) +} + +/// Normalize a path/command fragment to lowercase backslash form for the mock's +/// in-policy substring check. +fn mock_normalize(s: &str) -> String { + s.replace('/', "\\").to_lowercase() +} + +/// Per-process mock state: `iso:` sandbox id → granted read-write paths +/// (normalized). Populated by the mock provision, consumed by the mock exec to +/// decide whether the agent's write target is in-policy. +fn mock_grants() -> &'static Mutex>> { + static GRANTS: OnceLock>>> = OnceLock::new(); + GRANTS.get_or_init(|| Mutex::new(HashMap::new())) +} + // ── Request types ───────────────────────────────────────────────────────────── /// Filesystem shares for the sandbox (MXC provision-time only). @@ -133,6 +160,8 @@ impl InvokerError { pub struct WxcExecInvoker { exec_path: PathBuf, debug: bool, + /// When true, use the in-process mock instead of spawning `wxc-exec.exe`. + mock: bool, } impl WxcExecInvoker { @@ -140,12 +169,30 @@ impl WxcExecInvoker { Self { exec_path: exec_path.into(), debug, + mock: mock_enabled(), + } + } + + /// Test-only constructor that forces mock mode without touching the + /// process-global `OPENSHELL_MXC_MOCK_WXC` env var (avoids races/UB across + /// parallel tests under edition 2024's `unsafe` `set_var`). + #[cfg(test)] + pub(crate) fn mocked(exec_path: impl Into) -> Self { + Self { + exec_path: exec_path.into(), + debug: false, + mock: true, } } /// Encode `config` as base64 and invoke wxc-exec, returning the parsed envelope. /// Use this for all **non-exec** phases (provision/start/stop/deprovision). pub async fn run_phase(&self, config: &serde_json::Value) -> Result<(), InvokerError> { + if self.mock { + // Mock start/stop/deprovision: canned `{"result":{}}` success. + debug!(phase = ?config.get("phase"), "mock wxc-exec phase (no-op success)"); + return Ok(()); + } let json = serde_json::to_string(config)?; let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); @@ -198,6 +245,19 @@ impl WxcExecInvoker { configuration_id: &str, filesystem: MxcFilesystem, ) -> Result { + if self.mock { + // Mock provision: mint a synthetic `iso:` id and record the granted + // read-write paths so the mock exec can enforce the policy. + let id = format!("iso:mock-{}", uuid::Uuid::new_v4()); + let grants: Vec = filesystem + .readwrite_paths + .iter() + .map(|p| mock_normalize(p)) + .collect(); + mock_grants().lock().unwrap().insert(id.clone(), grants); + debug!(sandbox_id = %id, "mock wxc-exec provision"); + return Ok(id); + } let config = serde_json::json!({ "version": MXC_SCHEMA_VERSION, "phase": "provision", @@ -289,6 +349,9 @@ impl WxcExecInvoker { iso_sandbox_id: &str, process: MxcProcess, ) -> Result { + if self.mock { + return self.mock_spawn_exec(iso_sandbox_id, &process); + } let config = serde_json::json!({ "version": MXC_SCHEMA_VERSION, "phase": "exec", @@ -320,6 +383,45 @@ impl WxcExecInvoker { Ok(child) } + /// Mock exec: simulate AppContainer filesystem-policy enforcement. + /// + /// The agent's write target is considered **in-policy** iff the command line + /// references one of the granted read-write paths recorded at mock provision. + /// In-policy → run the real agent command (so the positive-proof artifact, + /// e.g. `hello.txt`, actually appears on the host shared folder). Out-of-policy + /// → refuse with an access-denied message on stderr and a non-zero exit, + /// mirroring how the AppContainer denies the write on the demo box. + fn mock_spawn_exec( + &self, + iso_sandbox_id: &str, + process: &MxcProcess, + ) -> Result { + let grants = mock_grants() + .lock() + .unwrap() + .get(iso_sandbox_id) + .cloned() + .unwrap_or_default(); + let cmd_norm = mock_normalize(&process.command_line); + let in_policy = grants.iter().any(|g| !g.is_empty() && cmd_norm.contains(g)); + + let mut cmd = Command::new("cmd"); + cmd.stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + if in_policy { + debug!(sandbox_id = %iso_sandbox_id, command = %process.command_line, "mock exec: in-policy, running agent"); + cmd.arg("/c").arg(&process.command_line); + } else { + debug!(sandbox_id = %iso_sandbox_id, command = %process.command_line, "mock exec: OUT-OF-POLICY, denying"); + // Emit an access-denied message to stderr and exit non-zero. + cmd.arg("/c") + .arg("echo Access is denied. (out-of-policy write blocked by AppContainer) 1>&2& exit 1"); + } + let child = cmd.spawn()?; + Ok(child) + } + /// Run the stop phase. pub async fn stop(&self, iso_sandbox_id: &str) -> Result<(), InvokerError> { let config = serde_json::json!({ diff --git a/crates/openshell-driver-mxc/src/policy.rs b/crates/openshell-driver-mxc/src/policy.rs index efa6cc4d11..40268ab22c 100644 --- a/crates/openshell-driver-mxc/src/policy.rs +++ b/crates/openshell-driver-mxc/src/policy.rs @@ -3,14 +3,23 @@ //! PolicyMapper seam: `SandboxPolicy` → MXC `ContainerConfig` fragment. //! -//! This skill does **not** write the actual policy mapping — that is -//! Giedrius's Rust mapper crate. This module defines the trait seam and ships -//! a minimal `StubPolicyMapper` that is only sufficient to compile and run -//! unit tests. Wire Giedrius's crate as the primary binding once it lands. +//! This skill does **not** write the actual policy mapping rules — that is +//! Giedrius's logic, **embedded** as the [`crate::policy_map`] module (team +//! decision: a module in this crate, not a separate crate). This file defines +//! the trait seam plus: //! -//! **Rule: never silently drop policy.** Unmappable rules must surface as -//! `MapError::Unsupported` and be rejected in `ValidateSandboxCreate`. +//! - [`EmbeddedPolicyMapper`] — the **primary** impl. Bridges the +//! `SandboxPolicy` proto into the `serde_yaml::Value` shape the embedded +//! mapper expects, calls [`crate::policy_map::build_mxc_config`], extracts the +//! MXC filesystem shares, and rejects the create on any `error`-severity loss. +//! - [`StubPolicyMapper`] — a compile-only fallback that grants only the demo +//! `share_dir`. Kept so the crate builds/tests without exercising the embed. +//! +//! **Rule: never silently drop policy.** Unmappable rules surface as +//! `MapError::Unsupported` and are rejected in `ValidateSandboxCreate`. +use openshell_core::proto::SandboxPolicy; +use serde_yaml::{Mapping, Value as YamlValue}; use thiserror::Error; /// The MXC config fragment derived from a `SandboxPolicy`. @@ -28,11 +37,11 @@ pub struct MappedConfig { /// Context passed to the mapper alongside the policy. #[derive(Debug)] pub struct MapCtx { - /// Sandbox ID (gateway-assigned). Used by the real PolicyMapper to correlate - /// policy lookups; unused by the stub. - #[allow(dead_code)] + /// Sandbox ID (gateway-assigned). Used as the MXC `containerId` and to + /// correlate diagnostics. pub sandbox_id: String, - /// Host share directory for the demo positive proof. + /// Host share directory for the demo positive proof. Always granted + /// read-write so `hello.txt` is visible on the host. pub share_dir: Option, } @@ -44,12 +53,7 @@ pub struct LossItem { } /// Error returned when policy translation fails or is incomplete. -/// -/// Variants are constructed by the real `PolicyMapper` implementation (Giedrius's -/// crate). The `StubPolicyMapper` does not construct them — hence the `dead_code` -/// allow below; they are part of the public seam contract. #[derive(Debug, Error)] -#[allow(dead_code)] pub enum MapError { #[error("policy rule(s) cannot be enforced by the MXC driver: {}", format_loss(.0))] Unsupported(Vec), @@ -67,26 +71,200 @@ fn format_loss(items: &[LossItem]) -> String { /// Translates an OpenShell `SandboxPolicy` into an MXC `ContainerConfig` /// fragment, returning a loss report of anything unrepresentable. -/// -/// The implementing crate (Giedrius's mapper) is bound behind this trait. -/// The `StubPolicyMapper` ships as a compile-only fallback. pub trait PolicyMapper: Send + Sync { - fn map(&self, ctx: &MapCtx) -> Result; + /// `policy` is `None` only when the gateway failed to stage one (the MXC + /// path treats that as a hard error — the demo's whole point is enforcement). + fn map(&self, policy: Option<&SandboxPolicy>, ctx: &MapCtx) -> Result; +} + +// ── Path normalization ────────────────────────────────────────────────────── + +/// Normalize forward-slash paths to Windows backslash form. Path normalization +/// lives here (the bridge), in one place — Giedrius's mapper passes path strings +/// through unchanged. +fn normalize_path(p: &str) -> String { + p.replace('/', "\\") +} + +// ── Embedded mapper (primary impl) ────────────────────────────────────────── + +/// Primary `PolicyMapper`: bridges the proto policy into the embedded +/// `policy_map` module (vendored from Giedrius's mapper). +pub struct EmbeddedPolicyMapper; + +/// Convert the `SandboxPolicy` proto IR into the `serde_yaml::Value` the embedded +/// mapper consumes. **Key bridge fact:** the proto's `filesystem` field maps to +/// the YAML key **`filesystem_policy`** (the name the mapper reads). +fn policy_to_yaml(policy: &SandboxPolicy) -> YamlValue { + let mut root = Mapping::new(); + + if let Some(fs) = &policy.filesystem { + let mut fs_map = Mapping::new(); + let rw: Vec = fs + .read_write + .iter() + .map(|p| YamlValue::String(normalize_path(p))) + .collect(); + let ro: Vec = fs + .read_only + .iter() + .map(|p| YamlValue::String(normalize_path(p))) + .collect(); + fs_map.insert(YamlValue::from("read_write"), YamlValue::Sequence(rw)); + fs_map.insert(YamlValue::from("read_only"), YamlValue::Sequence(ro)); + fs_map.insert( + YamlValue::from("include_workdir"), + YamlValue::Bool(fs.include_workdir), + ); + root.insert(YamlValue::from("filesystem_policy"), YamlValue::Mapping(fs_map)); + } + + if let Some(landlock) = &policy.landlock { + if !landlock.compatibility.is_empty() { + let mut ll = Mapping::new(); + ll.insert( + YamlValue::from("compatibility"), + YamlValue::from(landlock.compatibility.clone()), + ); + root.insert(YamlValue::from("landlock"), YamlValue::Mapping(ll)); + } + } + + if let Some(process) = &policy.process { + if !process.run_as_user.is_empty() || !process.run_as_group.is_empty() { + let mut p = Mapping::new(); + if !process.run_as_user.is_empty() { + p.insert( + YamlValue::from("run_as_user"), + YamlValue::from(process.run_as_user.clone()), + ); + } + if !process.run_as_group.is_empty() { + p.insert( + YamlValue::from("run_as_group"), + YamlValue::from(process.run_as_group.clone()), + ); + } + root.insert(YamlValue::from("process"), YamlValue::Mapping(p)); + } + } + + if !policy.network_policies.is_empty() { + let mut nets = Mapping::new(); + for (name, rule) in &policy.network_policies { + let mut rule_map = Mapping::new(); + let endpoints: Vec = rule + .endpoints + .iter() + .map(|ep| { + let mut e = Mapping::new(); + if !ep.host.is_empty() { + e.insert(YamlValue::from("host"), YamlValue::from(ep.host.clone())); + } + if ep.port != 0 { + e.insert(YamlValue::from("port"), YamlValue::from(ep.port)); + } + if !ep.protocol.is_empty() { + e.insert( + YamlValue::from("protocol"), + YamlValue::from(ep.protocol.clone()), + ); + } + YamlValue::Mapping(e) + }) + .collect(); + rule_map.insert(YamlValue::from("endpoints"), YamlValue::Sequence(endpoints)); + let binaries: Vec = rule + .binaries + .iter() + .map(|b| { + let mut bm = Mapping::new(); + bm.insert(YamlValue::from("path"), YamlValue::from(b.path.clone())); + YamlValue::Mapping(bm) + }) + .collect(); + rule_map.insert(YamlValue::from("binaries"), YamlValue::Sequence(binaries)); + nets.insert(YamlValue::from(name.clone()), YamlValue::Mapping(rule_map)); + } + root.insert(YamlValue::from("network_policies"), YamlValue::Mapping(nets)); + } + + YamlValue::Mapping(root) +} + +fn extract_paths(config: &serde_json::Value, key: &str) -> Vec { + config["filesystem"][key] + .as_array() + .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()) + .unwrap_or_default() +} + +impl PolicyMapper for EmbeddedPolicyMapper { + fn map(&self, policy: Option<&SandboxPolicy>, ctx: &MapCtx) -> Result { + let policy = policy.ok_or_else(|| { + MapError::Internal( + "MXC driver requires a sandbox policy, but none was staged for this sandbox" + .to_owned(), + ) + })?; + + let yaml = policy_to_yaml(policy); + let opts = crate::policy_map::MappingOptions::for_isolation_session(ctx.sandbox_id.clone()); + let mut losses = Vec::new(); + let config = crate::policy_map::build_mxc_config(&yaml, &opts, &mut losses); + + // Reject the create on any error-severity loss. Warnings/info (e.g. the + // filesystem default-deny note) are advisory and do not block. + let errors: Vec = losses + .iter() + .filter(|i| i.severity == "error") + .map(|i| LossItem { + rule_kind: i.path.clone(), + detail: i.message.clone(), + }) + .collect(); + if !errors.is_empty() { + return Err(MapError::Unsupported(errors)); + } + + let mut readwrite = extract_paths(&config, "readwritePaths"); + let readonly = extract_paths(&config, "readonlyPaths"); + + // Always grant the demo host-visible share read-write so the positive + // proof artifact (`hello.txt`) appears on the host. For the demo this + // equals the policy's read_write path, so it does not broaden access. + if let Some(dir) = &ctx.share_dir { + let norm = normalize_path(dir); + if !readwrite.contains(&norm) { + readwrite.push(norm); + } + } + + Ok(MappedConfig { + readwrite_paths: readwrite, + readonly_paths: readonly, + }) + } } -// ── Stub implementation ─────────────────────────────────────────────────────── +// ── Stub implementation (compile-only fallback) ───────────────────────────── /// Compile-only stub that applies only the demo's filesystem grant. /// -/// Maps `ctx.share_dir` as a read-write path. Rejects any other policy rule. -/// **Not sufficient for a live agent run** — replace with Giedrius's crate. +/// Ignores the policy and maps `ctx.share_dir` as a read-write path. Kept so the +/// crate compiles/tests without exercising the embedded mapper. **Not sufficient +/// for a meaningful policy demo** — use [`EmbeddedPolicyMapper`]. +/// +/// Retained as a documented scaffolding fallback (see SKILL Step 7); the default +/// backend uses [`EmbeddedPolicyMapper`], so this is unused outside tests. +#[allow(dead_code)] pub struct StubPolicyMapper; impl PolicyMapper for StubPolicyMapper { - fn map(&self, ctx: &MapCtx) -> Result { + fn map(&self, _policy: Option<&SandboxPolicy>, ctx: &MapCtx) -> Result { let mut config = MappedConfig::default(); if let Some(ref dir) = ctx.share_dir { - config.readwrite_paths.push(dir.clone()); + config.readwrite_paths.push(normalize_path(dir)); } Ok(config) } @@ -95,6 +273,7 @@ impl PolicyMapper for StubPolicyMapper { #[cfg(test)] mod tests { use super::*; + use openshell_core::proto::FilesystemPolicy; fn demo_ctx(share_dir: Option<&str>) -> MapCtx { MapCtx { @@ -103,21 +282,67 @@ mod tests { } } + fn fs_policy(rw: &[&str], ro: &[&str]) -> SandboxPolicy { + SandboxPolicy { + filesystem: Some(FilesystemPolicy { + include_workdir: false, + read_only: ro.iter().map(|s| s.to_string()).collect(), + read_write: rw.iter().map(|s| s.to_string()).collect(), + }), + ..Default::default() + } + } + #[test] fn stub_maps_share_dir_as_readwrite() { let mapper = StubPolicyMapper; let ctx = demo_ctx(Some("C:\\work\\demo")); - let config = mapper.map(&ctx).unwrap(); + let config = mapper.map(None, &ctx).unwrap(); assert_eq!(config.readwrite_paths, vec!["C:\\work\\demo"]); assert!(config.readonly_paths.is_empty()); } #[test] - fn stub_produces_empty_config_without_share_dir() { - let mapper = StubPolicyMapper; - let ctx = demo_ctx(None); - let config = mapper.map(&ctx).unwrap(); - assert!(config.readwrite_paths.is_empty()); - assert!(config.readonly_paths.is_empty()); + fn embedded_maps_policy_read_write_to_share() { + let mapper = EmbeddedPolicyMapper; + let policy = fs_policy(&["C:/work/openshell-mxc-demo"], &["C:/tools"]); + let ctx = demo_ctx(Some("C:/work/openshell-mxc-demo")); + let config = mapper.map(Some(&policy), &ctx).unwrap(); + // Forward slashes normalized to Windows backslashes by the bridge. + assert!(config + .readwrite_paths + .contains(&"C:\\work\\openshell-mxc-demo".to_string())); + assert_eq!(config.readonly_paths, vec!["C:\\tools"]); + } + + #[test] + fn embedded_rejects_missing_policy() { + let mapper = EmbeddedPolicyMapper; + let ctx = demo_ctx(Some("C:/work/demo")); + let err = mapper.map(None, &ctx).unwrap_err(); + assert!(matches!(err, MapError::Internal(_))); + } + + #[test] + fn embedded_rejects_network_policy_on_isolation_session() { + use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule}; + let mut policy = fs_policy(&["C:/work/demo"], &[]); + policy.network_policies.insert( + "api".to_string(), + NetworkPolicyRule { + name: "api".into(), + endpoints: vec![NetworkEndpoint { + host: "example.com".into(), + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".into(), + harness: false, + }], + }, + ); + let ctx = demo_ctx(Some("C:/work/demo")); + let err = mapper.map(Some(&policy), &ctx).unwrap_err(); + assert!(matches!(err, MapError::Unsupported(_))); } } diff --git a/crates/openshell-driver-mxc/src/policy_map.rs b/crates/openshell-driver-mxc/src/policy_map.rs new file mode 100644 index 0000000000..12634cb81e --- /dev/null +++ b/crates/openshell-driver-mxc/src/policy_map.rs @@ -0,0 +1,958 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// vendored from gburachas/msft-mxc@a66cc35 (branch `policy_mapper`, +// policy_mapper/rust_policy_mapper/src/main.rs). +// +//! Embedded OpenShell-policy → MXC `ContainerConfig` mapping logic. +//! +//! This module is **vendored** from Giedrius's `rust_policy_mapper` CLI tool +//! (team decision: embed as a module, NOT a separate crate). Only the **pure** +//! mapping functions are lifted; the CLI shell (`clap`/`Args`/`main`), file +//! discovery, and output writing (`convert_policy`/`load_yaml`/`write_outputs`/ +//! `render_readme`/`build_loss_report`) are dropped, along with the `clap`, +//! `anyhow`, and `regex` dependencies they pulled in. +//! +//! Giedrius's `validate_schema` + the `jsonschema` dependency are intentionally +//! **omitted** here (per the embed plan: "not needed at runtime"). Re-add behind +//! a `schema-validation` feature if parity-validation is ever wanted in-crate. +//! +//! This module is pure `serde` and is **NOT** `#[cfg(target_os = "windows")]` +//! gated, so its parity tests run on Linux CI even though the rest of the driver +//! is Windows-only. +//! +//! **Sync plan:** Giedrius's repo stays the source of truth. Re-vendor when he +//! updates `rust_policy_mapper`; bump the `@a66cc35` marker above. The Python +//! reference for behavior is +//! `msft-mxc-gburachas/policy_mapper/python_policy_mapper/openshell_policy_to_mxc.py`. + +// Vendored module: the full mapping surface (network/L7 loss reporting, loss +// summaries) is carried for parity with Giedrius's source and Stage-2 egress, +// but the June 15 filesystem-only demo does not exercise all of it. The module +// is also compiled-but-unused on non-Windows targets (only its tests use it +// there). Both are by design, so suppress dead-code noise crate-wide here. +#![allow(dead_code)] + +use serde::Serialize; +use serde_json::{Value as JsonValue, json}; +use serde_yaml::{Mapping, Value as YamlValue}; +use std::collections::HashSet; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const DEFAULT_COMMAND: &str = + "sh -lc \"echo OpenShell policy mapped to MXC; replace process.commandLine before running a real workload\""; +const DEFAULT_MXC_VERSION: &str = "0.7.0-alpha"; + +const OPEN_SHELL_SUPERSET_GAPS: &[&str] = &[ + "MXC UI policy has no OpenShell policy equivalent: ui.disable, ui.clipboard, and ui.injection.", + "MXC lifecycle fields have no OpenShell policy equivalent: destroyOnExit, preservePolicy, phase, and sandboxId.", + "MXC backend selection and backend-specific blocks are outside OpenShell policy YAML.", + "MXC process command, cwd, env, and timeout are runtime config fields, not OpenShell policy fields.", + "MXC explicit deniedPaths are not expressible in current OpenShell policy YAML, which relies on default-deny filesystem behavior instead.", + "MXC fallback.allowDaclMutation (host DACL mutation consent) has no OpenShell policy equivalent.", + "MXC network.allowLocalNetwork (inbound bind/listen permission) has no OpenShell policy equivalent.", + "MXC network.proxy configuration has no OpenShell policy equivalent.", + "MXC experimental backend blocks (windows_sandbox, wslc, seatbelt, isolation_session) are outside OpenShell policy YAML.", +]; + +// --------------------------------------------------------------------------- +// Mapping options (clone-friendly runtime config) +// --------------------------------------------------------------------------- + +/// Runtime knobs for the mapper. The driver constructs these with +/// [`MappingOptions::for_isolation_session`]; the upstream CLI `Args`/`build_options` +/// path is dropped. +#[derive(Clone, Debug)] +pub struct MappingOptions { + pub mxc_version: String, + pub containment: String, + pub command: String, + pub container_id: String, + pub cwd: Option, + pub env: Vec, + pub timeout_ms: u64, + pub strict: bool, + pub allow_wildcards: bool, +} + +impl MappingOptions { + /// Demo/driver defaults: target the MXC `isolation_session` backend. + pub fn for_isolation_session(container_id: impl Into) -> Self { + Self { + mxc_version: DEFAULT_MXC_VERSION.to_owned(), + containment: "isolation_session".to_owned(), + command: DEFAULT_COMMAND.to_owned(), + container_id: container_id.into(), + cwd: None, + env: Vec::new(), + timeout_ms: 0, + strict: false, + allow_wildcards: false, + } + } +} + +// --------------------------------------------------------------------------- +// Loss item +// --------------------------------------------------------------------------- + +/// A single OpenShell→MXC mapping loss/diagnostic. `severity` is one of +/// `"info"`, `"warning"`, `"error"`. The driver rejects a `CreateSandbox` +/// when any `"error"` item is present. +#[derive(Clone, Debug, Serialize)] +pub struct LossItem { + pub path: String, + pub severity: String, + pub message: String, + pub openshell_feature: String, + pub mxc_impact: String, +} + +fn add_loss( + items: &mut Vec, + path: &str, + severity: &str, + message: &str, + openshell_feature: &str, + mxc_impact: &str, +) { + items.push(LossItem { + path: path.to_owned(), + severity: severity.to_owned(), + message: message.to_owned(), + openshell_feature: openshell_feature.to_owned(), + mxc_impact: mxc_impact.to_owned(), + }); +} + +// --------------------------------------------------------------------------- +// MXC config builder (pure entry point lifted from Giedrius's mapper) +// --------------------------------------------------------------------------- + +/// Translate an OpenShell policy (as a `serde_yaml::Value`) into an MXC +/// `ContainerConfig` JSON value, appending any mapping losses to `items`. +/// +/// Top-level YAML keys consumed: `filesystem_policy`, `network_policies`, +/// `landlock`, `process`. +pub fn build_mxc_config( + policy: &YamlValue, + options: &MappingOptions, + items: &mut Vec, +) -> JsonValue { + let mut process = json!({ + "commandLine": options.command, + "timeout": options.timeout_ms, + }); + if let Some(cwd) = &options.cwd { + process["cwd"] = json!(cwd); + } + if !options.env.is_empty() { + process["env"] = json!(options.env); + } + + let filesystem = map_filesystem(policy, options, items); + let allowed_hosts = map_network(policy, options, items); + + let mut network = json!({ + "defaultPolicy": "block", + "allowedHosts": allowed_hosts, + "blockedHosts": [], + }); + if let Some(mode) = default_enforcement_mode(&options.containment, &allowed_hosts) { + network["enforcementMode"] = json!(mode); + } + + let mut config = json!({ + "version": options.mxc_version, + "containerId": options.container_id, + "containment": options.containment, + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false, + }, + "process": process, + "filesystem": filesystem, + "network": network, + "ui": { + "disable": true, + "clipboard": "none", + "injection": false, + }, + }); + + add_backend_specific_config(&mut config, &options.containment, &allowed_hosts, items); + add_static_policy_loss(policy, options, items); + config +} + +// --------------------------------------------------------------------------- +// Filesystem mapping +// --------------------------------------------------------------------------- + +fn map_filesystem(policy: &YamlValue, options: &MappingOptions, items: &mut Vec) -> JsonValue { + let raw_fs = policy.get("filesystem_policy"); + + // Python: fs_policy = policy.get("filesystem_policy") or {} + // Falsy values (None/null/empty dict) collapse to empty dict. + enum FsResult<'a> { + Map(&'a Mapping), + EmptyOrAbsent, + TypeError, + } + + let fs_result = match raw_fs { + None | Some(YamlValue::Null) => FsResult::EmptyOrAbsent, + Some(YamlValue::Mapping(m)) if m.is_empty() => FsResult::EmptyOrAbsent, + Some(YamlValue::Mapping(m)) => FsResult::Map(m), + Some(_) => FsResult::TypeError, + }; + + if matches!(fs_result, FsResult::TypeError) { + add_loss( + items, + "filesystem_policy", + "error", + "Expected filesystem_policy to be an object.", + "filesystem policy", + "No filesystem grants could be mapped.", + ); + } + + let mut readwrite: Vec = Vec::new(); + let mut readonly: Vec = Vec::new(); + + if let FsResult::Map(fs) = &fs_result { + readwrite = stable_list(fs.get("read_write")); + readonly = stable_list(fs.get("read_only")); + + let include_workdir = fs + .get("include_workdir") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + if include_workdir { + if let Some(cwd) = &options.cwd { + append_unique(&mut readwrite, cwd.clone()); + } else { + add_loss( + items, + "filesystem_policy.include_workdir", + "info", + "OpenShell includes the runtime workdir, but no cwd was supplied.", + "include_workdir", + "The generated MXC config cannot add the workdir path grant.", + ); + } + } + } + + // Python: `if not fs_policy:` fires when dict is empty/absent (all non-Map cases). + if !matches!(fs_result, FsResult::Map(_)) { + add_loss( + items, + "filesystem_policy", + "warning", + "No OpenShell filesystem_policy was present.", + "default filesystem policy", + "MXC receives empty filesystem lists; backend defaults determine visibility.", + ); + } + + add_loss( + items, + "filesystem_policy", + "warning", + &filesystem_default_deny_message(&options.containment), + "OpenShell Landlock/default-deny filesystem model", + "MXC filesystem default-deny parity is backend-specific.", + ); + + json!({ + "readwritePaths": readwrite, + "readonlyPaths": readonly, + "deniedPaths": [], + }) +} + +fn filesystem_default_deny_message(containment: &str) -> String { + match containment { + "bubblewrap" => "Bubblewrap policy is not strict OpenShell filesystem parity: MXC \ + may bind host root read-only and overlay policy mounts." + .to_owned(), + "lxc" => "LXC exposes the container rootfs and bind-mounts selected host \ + paths; this is not identical to OpenShell Landlock." + .to_owned(), + "wslc" => "WSLC mounts selected Windows paths, but default-deny behavior is \ + runner/backend specific." + .to_owned(), + "seatbelt" => "Seatbelt starts from a deny-default profile with baseline system \ + allowances, not OpenShell Landlock." + .to_owned(), + _ => "MXC filesystem behavior is backend-specific and not equivalent to \ + OpenShell Landlock by construction." + .to_owned(), + } +} + +// --------------------------------------------------------------------------- +// Network mapping +// --------------------------------------------------------------------------- + +fn map_network(policy: &YamlValue, options: &MappingOptions, items: &mut Vec) -> Vec { + let raw_net = policy.get("network_policies"); + + let net_map = match raw_net { + None | Some(YamlValue::Null) => { + add_backend_network_loss(policy, &options.containment, items); + return vec![]; + } + Some(YamlValue::Mapping(m)) if m.is_empty() => { + add_backend_network_loss(policy, &options.containment, items); + return vec![]; + } + Some(YamlValue::Mapping(m)) => m, + Some(_) => { + add_loss( + items, + "network_policies", + "error", + "Expected network_policies to be a map.", + "network policies", + "No network allowlist could be mapped.", + ); + add_backend_network_loss(policy, &options.containment, items); + return vec![]; + } + }; + + let mut allowed_hosts: Vec = Vec::new(); + + for (key_val, rule_val) in net_map.iter() { + let rule_key = yaml_as_str(key_val).unwrap_or_default(); + let rule_path = format!("network_policies.{}", rule_key); + + let rule = match rule_val.as_mapping() { + Some(m) => m, + None => { + add_loss( + items, + &rule_path, + "error", + "Expected network policy entry to be an object.", + "network policy entry", + "Entry was skipped.", + ); + continue; + } + }; + + let endpoints: Vec<&YamlValue> = match rule.get("endpoints") { + None | Some(YamlValue::Null) => vec![], + Some(YamlValue::Sequence(seq)) => seq.iter().collect(), + Some(other) => vec![other], + }; + + if endpoints.is_empty() { + add_loss( + items, + &format!("{}.endpoints", rule_path), + "error", + "OpenShell policy entry has no endpoints.", + "network endpoints", + "No MXC host allowlist entries were produced for this policy.", + ); + } + + for (index, endpoint) in endpoints.iter().enumerate() { + let endpoint_path = format!("{}.endpoints[{}]", rule_path, index); + match endpoint.as_mapping() { + None => { + add_loss( + items, + &endpoint_path, + "error", + "Expected endpoint to be an object.", + "network endpoint", + "Endpoint was skipped.", + ); + } + Some(ep) => { + map_endpoint(ep, &endpoint_path, &mut allowed_hosts, options, items); + } + } + } + + // binaries + let binaries: Vec<&YamlValue> = match rule.get("binaries") { + None | Some(YamlValue::Null) => vec![], + Some(YamlValue::Sequence(seq)) => seq.iter().collect(), + Some(other) => vec![other], + }; + + if binaries.is_empty() { + add_loss( + items, + &format!("{}.binaries", rule_path), + "error", + "OpenShell requires binary-scoped network grants; this entry has no binaries.", + "binary-scoped network policy", + "MXC cannot represent per-binary grants and scopes network to the sandbox.", + ); + } else { + for (index, binary) in binaries.iter().enumerate() { + let binary_path = match binary.as_mapping().and_then(|m| m.get("path")) { + Some(YamlValue::String(s)) => Some(s.as_str()), + _ => None, + }; + let repr = match binary_path { + Some(p) => format!("'{}'", p), + None => python_repr_yaml(binary), + }; + add_loss( + items, + &format!("{}.binaries[{}].path", rule_path, index), + "error", + &format!("Binary scope is not representable in MXC: {}.", repr), + "binary-scoped network policy", + "Dropping this would broaden access from one executable to the whole sandbox.", + ); + } + } + } + + add_backend_network_loss(policy, &options.containment, items); + allowed_hosts +} + +fn map_endpoint( + endpoint: &Mapping, + path: &str, + allowed_hosts: &mut Vec, + options: &MappingOptions, + items: &mut Vec, +) { + // host + match endpoint.get("host") { + None | Some(YamlValue::Null) => { + add_loss( + items, + &format!("{}.host", path), + "error", + "Endpoint has no host.", + "network endpoint host", + "Endpoint was not added to MXC allowedHosts.", + ); + } + Some(host_val) => { + let host_str = yaml_to_string(host_val); + if contains_wildcard(&host_str) { + let (message, impact) = if options.allow_wildcards { + append_unique(allowed_hosts, host_str.clone()); + ( + format!( + "Wildcard host emitted despite non-portable MXC semantics: {}.", + host_str + ), + "Backend behavior is not portable and may fail or broaden access.", + ) + } else { + ( + format!( + "Wildcard host omitted because MXC has no portable syntax: {}.", + host_str + ), + "Generated MXC config is more restrictive for this endpoint.", + ) + }; + add_loss( + items, + &format!("{}.host", path), + "error", + &message, + "OpenShell wildcard host matching", + impact, + ); + } else { + append_unique(allowed_hosts, host_str); + } + } + } + + // port / ports + for field in &["port", "ports"] { + if let Some(val) = endpoint.get(*field) { + if !matches!(val, YamlValue::Null) { + let repr = yaml_repr_value(val); + add_loss( + items, + &format!("{}.{}", path, field), + "error", + &format!("MXC allowedHosts cannot encode port constraint {}.", repr), + "port-scoped outbound policy", + "MXC allows or blocks the host as a whole.", + ); + } + } + } + + // allowed_ips + let ips = stable_list(endpoint.get("allowed_ips")); + let host_for_msg = endpoint.get("host").map(yaml_to_string).unwrap_or_default(); + for ip in ips { + append_unique(allowed_hosts, ip.clone()); + add_loss( + items, + &format!("{}.allowed_ips", path), + "warning", + &format!( + "MXC can carry CIDR/IP '{}', but cannot bind it to DNS for '{}'.", + ip, host_for_msg + ), + "DNS result pinning / SSRF override", + "The CIDR/IP becomes a standalone allowed destination.", + ); + } + + report_endpoint_l7_losses(endpoint, path, items); +} + +fn report_endpoint_l7_losses(endpoint: &Mapping, path: &str, items: &mut Vec) { + if let Some(protocol) = endpoint.get("protocol").and_then(|v| v.as_str()) { + add_loss( + items, + &format!("{}.protocol", path), + "error", + &format!("MXC has no protocol-aware policy equivalent for '{}'.", protocol), + "protocol-aware proxy policy", + "MXC host filtering cannot enforce REST/WebSocket/GraphQL semantics.", + ); + } + + if let Some(tls) = endpoint.get("tls").and_then(|v| v.as_str()) { + let severity = if tls == "skip" { "warning" } else { "error" }; + add_loss( + items, + &format!("{}.tls", path), + severity, + &format!("MXC has no OpenShell TLS inspection mode equivalent for '{}'.", tls), + "TLS inspection mode", + "MXC network policy is host-level only.", + ); + } + + if let Some(enforcement) = endpoint.get("enforcement").and_then(|v| v.as_str()) { + if enforcement == "audit" { + add_loss( + items, + &format!("{}.enforcement", path), + "error", + "MXC has no audit-only network policy mode.", + "audit-mode endpoint", + "Generated MXC config enforces host-level default block instead.", + ); + } else { + add_loss( + items, + &format!("{}.enforcement", path), + "warning", + "MXC enforcementMode is backend-wide, not per endpoint.", + "per-endpoint enforcement", + "The mapper chooses a backend-level enforcement mode.", + ); + } + } + + if let Some(access) = endpoint.get("access").and_then(|v| v.as_str()) { + add_loss( + items, + &format!("{}.access", path), + "error", + &format!("MXC has no access preset equivalent for '{}'.", access), + "REST/WebSocket/GraphQL access preset", + "MXC cannot enforce method or operation-level access.", + ); + } + + if endpoint.get("rules").is_some_and(|v| !matches!(v, YamlValue::Null)) { + add_loss( + items, + &format!("{}.rules", path), + "error", + "MXC has no L7 allow-rule equivalent.", + "REST/WebSocket/GraphQL allow rules", + "Method/path/query/operation restrictions are lost.", + ); + } + + if endpoint + .get("deny_rules") + .is_some_and(|v| !matches!(v, YamlValue::Null)) + { + add_loss( + items, + &format!("{}.deny_rules", path), + "error", + "MXC has no L7 deny-rule equivalent.", + "L7 deny rules", + "Deny precedence over broad allows is lost.", + ); + } + + // boolean L7 losses + let bool_losses: &[(&str, &str)] = &[ + ("allow_encoded_slash", "encoded slash handling"), + ("websocket_credential_rewrite", "WebSocket credential rewrite"), + ("request_body_credential_rewrite", "request-body credential rewrite"), + ]; + for (field, feature) in bool_losses { + if endpoint.get(*field).and_then(|v| v.as_bool()).unwrap_or(false) { + add_loss( + items, + &format!("{}.{}", path, field), + "error", + &format!("MXC has no equivalent for {}.", feature), + feature, + "Generated config cannot preserve this proxy behavior.", + ); + } + } + + // GraphQL losses + let graphql_fields: &[&str] = &[ + "persisted_queries", + "graphql_persisted_queries", + "graphql_max_body_bytes", + ]; + for field in graphql_fields { + if endpoint.get(*field).is_some_and(|v| !matches!(v, YamlValue::Null)) { + add_loss( + items, + &format!("{}.{}", path, field), + "error", + &format!("MXC has no GraphQL policy equivalent for {}.", field), + "GraphQL operation policy", + "GraphQL inspection and persisted-query behavior is lost.", + ); + } + } +} + +// --------------------------------------------------------------------------- +// Static policy losses (landlock, process identity) +// --------------------------------------------------------------------------- + +fn add_static_policy_loss(policy: &YamlValue, options: &MappingOptions, items: &mut Vec) { + if let Some(ll) = policy.get("landlock") { + if !matches!(ll, YamlValue::Null) { + add_loss( + items, + "landlock", + "warning", + "MXC has no Landlock compatibility mode field.", + "Landlock LSM enforcement", + "Backend filesystem controls may not fail like OpenShell best_effort/hard_requirement.", + ); + } + } + + if let Some(process) = policy.get("process").and_then(|v| v.as_mapping()) { + for field in &["run_as_user", "run_as_group"] { + if process.get(*field).is_some_and(|v| !matches!(v, YamlValue::Null)) { + add_loss( + items, + &format!("process.{}", field), + "warning", + &format!("MXC has no portable equivalent for OpenShell {}.", field), + "process identity", + "MXC backend identity is selected outside this policy mapping.", + ); + } + } + } + + if options.containment == "processcontainer" { + let fs = policy.get("filesystem_policy").and_then(|v| v.as_mapping()); + if let Some(fs_map) = fs { + let linux_paths: Vec = stable_list(fs_map.get("read_only")) + .into_iter() + .chain(stable_list(fs_map.get("read_write"))) + .collect(); + if linux_paths.iter().any(|p| p.starts_with('/')) { + add_loss( + items, + "filesystem_policy", + "warning", + "OpenShell example paths are Linux paths; Windows ProcessContainer expects Windows paths.", + "filesystem path syntax", + "Run with path translation or target a Linux-like MXC backend.", + ); + } + } + } +} + +// --------------------------------------------------------------------------- +// Backend-specific config additions +// --------------------------------------------------------------------------- + +fn add_backend_specific_config( + config: &mut JsonValue, + containment: &str, + allowed_hosts: &[String], + items: &mut Vec, +) { + match containment { + "processcontainer" | "process" => { + if !allowed_hosts.is_empty() { + config["processContainer"] = json!({"capabilities": ["internetClient"]}); + } + } + "lxc" => { + config["lxc"] = json!({"distribution": "alpine", "release": "3.20"}); + } + c @ ("windows_sandbox" | "isolation_session" | "vm") if !allowed_hosts.is_empty() => { + add_loss( + items, + "containment", + "error", + &format!("{} is not a v0 target for OpenShell network policy mapping.", c), + "OpenShell network policy", + "MXC network behavior is unsupported or unknown for this backend.", + ); + } + "microvm" if !allowed_hosts.is_empty() => { + add_loss( + items, + "containment", + "error", + "microvm network policy enforcement is not defined for this mapper.", + "OpenShell network policy", + "MXC network behavior is unsupported or unknown for microvm.", + ); + } + _ => {} + } +} + +fn add_backend_network_loss(policy: &YamlValue, containment: &str, items: &mut Vec) { + // Only fire when network_policies is present and non-empty + let has_net = policy + .get("network_policies") + .is_some_and(|v| matches!(v, YamlValue::Mapping(m) if !m.is_empty())); + if !has_net { + return; + } + + match containment { + "seatbelt" => { + add_loss( + items, + "network_policies", + "error", + "MXC Seatbelt cannot faithfully enforce arbitrary allowedHosts.", + "host allowlist", + "Seatbelt allowlists can broaden to allow-all outbound.", + ); + } + "processcontainer" | "process" => { + add_loss( + items, + "network_policies", + "warning", + "Windows ProcessContainer host allowlists are possible but fragile.", + "host allowlist", + "Review firewall/capability behavior before treating this as parity.", + ); + } + "wslc" => { + add_loss( + items, + "network_policies", + "warning", + "WSLC host filtering relies on bridged networking plus in-container iptables.", + "host allowlist", + "Backend privileges and runner behavior determine parity.", + ); + } + "vm" | "windows_sandbox" => { + add_loss( + items, + "network_policies", + "error", + "MXC Windows Sandbox / vm cannot faithfully enforce arbitrary allowedHosts.", + "host allowlist", + "Network policy enforcement is unsupported or unknown for this backend.", + ); + } + _ => {} + } +} + +fn default_enforcement_mode(containment: &str, allowed_hosts: &[String]) -> Option<&'static str> { + if allowed_hosts.is_empty() { + return None; + } + match containment { + "lxc" | "bubblewrap" | "hyperlight" => Some("firewall"), + "processcontainer" | "process" => Some("both"), + "wslc" | "seatbelt" | "microvm" | "vm" | "windows_sandbox" => None, + _ => Some("firewall"), + } +} + +// --------------------------------------------------------------------------- +// Loss summary helpers +// --------------------------------------------------------------------------- + +/// Distinct OpenShell features that could not be represented (error/warning). +pub fn summarize_missing_mxc(items: &[LossItem]) -> Vec { + let mut seen: HashSet = HashSet::new(); + let mut summary: Vec = Vec::new(); + for item in items { + if (item.severity == "error" || item.severity == "warning") + && !seen.contains(&item.openshell_feature) + { + seen.insert(item.openshell_feature.clone()); + summary.push(item.openshell_feature.clone()); + } + } + summary +} + +/// Static MXC features that have no OpenShell-policy equivalent. +pub fn open_shell_superset_gaps() -> &'static [&'static str] { + OPEN_SHELL_SUPERSET_GAPS +} + +// --------------------------------------------------------------------------- +// YAML utilities +// --------------------------------------------------------------------------- + +/// Convert a YAML value to a Vec following Python's `stable_list`. +/// None/null → []; sequence → flattened strings; scalar → [scalar]. +fn stable_list(v: Option<&YamlValue>) -> Vec { + match v { + None | Some(YamlValue::Null) => vec![], + Some(YamlValue::Sequence(seq)) => seq.iter().map(yaml_to_string).collect(), + Some(other) => vec![yaml_to_string(other)], + } +} + +fn yaml_to_string(v: &YamlValue) -> String { + match v { + YamlValue::String(s) => s.clone(), + YamlValue::Number(n) => n.to_string(), + YamlValue::Bool(b) => b.to_string(), + YamlValue::Null => String::new(), + _ => format!("{:?}", v), + } +} + +fn yaml_as_str(v: &YamlValue) -> Option<&str> { + v.as_str() +} + +/// Python repr-style formatting of a YAML scalar. +fn yaml_repr_value(v: &YamlValue) -> String { + match v { + YamlValue::Number(n) => n.to_string(), + YamlValue::String(s) => format!("'{}'", s), + YamlValue::Bool(b) => b.to_string(), + YamlValue::Null => "None".to_string(), + _ => format!("{:?}", v), + } +} + +/// Python repr for an arbitrary YAML value (used for binary path fallback). +fn python_repr_yaml(v: &YamlValue) -> String { + match v { + YamlValue::String(s) => format!("'{}'", s), + YamlValue::Number(n) => n.to_string(), + YamlValue::Bool(b) => b.to_string(), + YamlValue::Null => "None".to_string(), + YamlValue::Mapping(_) => "{...}".to_string(), + YamlValue::Sequence(_) => "[...]".to_string(), + _ => format!("{:?}", v), + } +} + +fn append_unique(list: &mut Vec, value: String) { + if !list.contains(&value) { + list.push(value); + } +} + +fn contains_wildcard(host: &str) -> bool { + host.contains('*') +} + +// --------------------------------------------------------------------------- +// Tests — parity against Giedrius's mapper behavior (run on Linux CI too) +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn fs_policy_yaml(rw: &[&str], ro: &[&str]) -> YamlValue { + let rw_seq: Vec<&str> = rw.to_vec(); + let ro_seq: Vec<&str> = ro.to_vec(); + serde_yaml::from_str(&format!( + "filesystem_policy:\n read_write: {:?}\n read_only: {:?}\n", + rw_seq, ro_seq + )) + .unwrap() + } + + #[test] + fn filesystem_read_write_maps_to_readwrite_paths() { + let policy = fs_policy_yaml(&["C:\\work\\demo"], &[]); + let opts = MappingOptions::for_isolation_session("demo"); + let mut items = Vec::new(); + let config = build_mxc_config(&policy, &opts, &mut items); + assert_eq!(config["filesystem"]["readwritePaths"][0], "C:\\work\\demo"); + assert_eq!( + config["filesystem"]["readonlyPaths"] + .as_array() + .map(|a| a.len()), + Some(0) + ); + // Filesystem-only policy: only warnings/info, no errors → not rejected. + assert_eq!(items.iter().filter(|i| i.severity == "error").count(), 0); + } + + #[test] + fn read_only_paths_map_through() { + let policy = fs_policy_yaml(&[], &["C:\\tools"]); + let opts = MappingOptions::for_isolation_session("demo"); + let mut items = Vec::new(); + let config = build_mxc_config(&policy, &opts, &mut items); + assert_eq!(config["filesystem"]["readonlyPaths"][0], "C:\\tools"); + } + + #[test] + fn network_policy_on_isolation_session_is_an_error_loss() { + // A network policy with an endpoint host produces an allowedHosts entry, + // which on isolation_session is an error (rejected by the driver). + let policy: YamlValue = serde_yaml::from_str( + "filesystem_policy:\n read_write: []\n read_only: []\nnetwork_policies:\n api:\n endpoints:\n - host: example.com\n binaries:\n - path: /usr/bin/curl\n", + ) + .unwrap(); + let opts = MappingOptions::for_isolation_session("demo"); + let mut items = Vec::new(); + let _ = build_mxc_config(&policy, &opts, &mut items); + assert!(items.iter().any(|i| i.severity == "error")); + } + + #[test] + fn type_error_filesystem_is_an_error_loss() { + let policy: YamlValue = serde_yaml::from_str("filesystem_policy: \"not-a-map\"\n").unwrap(); + let opts = MappingOptions::for_isolation_session("demo"); + let mut items = Vec::new(); + let _ = build_mxc_config(&policy, &opts, &mut items); + assert!(items.iter().any(|i| i.severity == "error")); + } +} From 6072f231a1a3fdeb23b5c731c6822b8cd9eda547 Mon Sep 17 00:00:00 2001 From: Jamie King Date: Mon, 8 Jun 2026 22:06:13 -0600 Subject: [PATCH 03/19] test(mxc): fix lifecycle and policy unit-test compile drift - Bring futures::StreamExt into scope for the watch-stream `.next()` call in driver::lifecycle_tests so the negative policy proof test compiles. - Bind a local `mapper` and drop the unused/deprecated NetworkBinary in the embedded-mapper network-policy rejection test. Signed-off-by: Jamie King (cherry picked from commit 039b0baf98735ca672dae52be8d3af2417dc0c1a) Signed-off-by: Jamie King --- crates/openshell-driver-mxc/src/driver.rs | 1 + crates/openshell-driver-mxc/src/policy.rs | 8 +++----- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index cdbfee55c3..b1bc68f856 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -662,6 +662,7 @@ fn make_sandbox_with_condition( #[cfg(test)] mod lifecycle_tests { use super::*; + use futures::StreamExt; use openshell_core::proto::compute::v1::DriverSandboxSpec; use openshell_core::proto::{FilesystemPolicy, SandboxPolicy}; use std::time::Duration; diff --git a/crates/openshell-driver-mxc/src/policy.rs b/crates/openshell-driver-mxc/src/policy.rs index 40268ab22c..da1ea94f4e 100644 --- a/crates/openshell-driver-mxc/src/policy.rs +++ b/crates/openshell-driver-mxc/src/policy.rs @@ -325,7 +325,8 @@ mod tests { #[test] fn embedded_rejects_network_policy_on_isolation_session() { - use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule}; + use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule}; + let mapper = EmbeddedPolicyMapper; let mut policy = fs_policy(&["C:/work/demo"], &[]); policy.network_policies.insert( "api".to_string(), @@ -335,10 +336,7 @@ mod tests { host: "example.com".into(), ..Default::default() }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".into(), - harness: false, - }], + binaries: Vec::new(), }, ); let ctx = demo_ctx(Some("C:/work/demo")); From c78544a5cc9dc0642fd2eec933e737e7b4e6c519 Mon Sep 17 00:00:00 2001 From: Jamie King Date: Mon, 8 Jun 2026 22:12:15 -0600 Subject: [PATCH 04/19] fix(mxc): downgrade missing sandbox_token to debug log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gateway mints `sandbox_token` only when a sandbox-JWT issuer is configured. There is no in-sandbox supervisor on MXC (supervisor-removal design — D1/D4), so no component ever consumes the token; requiring it on the driver side blocks the demo's `--disable-tls` smoke gateway with a spurious `invalid_argument`. Log the absence and proceed instead. Signed-off-by: Jamie King (cherry picked from commit cea209797d0edcb1d152251e748900b0a63cca62) Signed-off-by: Jamie King --- crates/openshell-driver-mxc/src/driver.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index b1bc68f856..74b0b6744b 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -250,12 +250,19 @@ impl MxcComputeBackend { self.validate_sandbox_create(sandbox)?; + // `sandbox_token` is minted by the gateway only when the sandbox-JWT + // issuer is configured. On MXC there is no in-sandbox supervisor that + // would ever consume it (the supervisor-removal design — D1/D4), so an + // absent token must not block create. Log it and move on. if sandbox .spec .as_ref() .map_or(true, |s| s.sandbox_token.is_empty()) { - return Err(tonic::Status::invalid_argument("sandbox_token is required")); + tracing::debug!( + sandbox = %sandbox.name, + "no sandbox_token minted (no supervisor consumer on MXC)" + ); } let sandbox_name = sandbox.name.clone(); From e4230b3291898600067060275086e01e1b2dd231 Mon Sep 17 00:00:00 2001 From: Jamie King Date: Mon, 8 Jun 2026 22:41:17 -0600 Subject: [PATCH 05/19] fix(mxc): keep sandbox Ready after a successful one-shot agent exec monitor_exec demoted Ready->Error on exit 0 (reason ExecCompleted), so the positive demo (write hello.txt + exit) landed in Error phase. Keep Ready=True (reason AgentCompleted) on success; only non-zero exits go to ExecFailed. Tighten the positive lifecycle test to assert the terminal condition stays Ready=True/AgentCompleted. Verified live via gateway mock round-trip: phase now Provisioning->Ready with no demotion. (cherry picked from commit 54ab030f03ca0f83d0050d8dd843b633651684ad) Signed-off-by: Jamie King --- crates/openshell-driver-mxc/src/driver.rs | 24 +++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 74b0b6744b..0999f665b1 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -556,13 +556,17 @@ async fn monitor_exec( match child.wait().await { Ok(status) if status.success() => { info!(sandbox = %sandbox.name, "MXC agent exec completed successfully"); + // A successful one-shot agent (exit 0) must NOT demote the sandbox to + // Error. The isolation session is still alive until stop/deprovision, + // and the demo's positive proof is "Ready + in-policy file written". + // Keep Ready=True so derive_phase leaves the public phase at Ready. let done = make_sandbox_with_condition( &sandbox, &DriverCondition { r#type: "Ready".into(), - status: "False".into(), - reason: "ExecCompleted".into(), - message: "Agent exec finished with exit code 0".into(), + status: "True".into(), + reason: "AgentCompleted".into(), + message: "Agent exec finished successfully (exit code 0)".into(), last_transition_time: String::new(), }, false, @@ -570,7 +574,7 @@ async fn monitor_exec( let mut reg = registry.lock().await; if let Some(entry) = reg.get_mut(&sandbox_id) { entry.sandbox = done.clone(); - entry.phase_state = PhaseState::Stopped; + entry.phase_state = PhaseState::Running; } drop(reg); let _ = watch_tx.send(sandbox_event(done)); @@ -774,6 +778,18 @@ mod lifecycle_tests { tokio::time::sleep(Duration::from_millis(100)).await; } assert!(found, "hello.txt should appear in the granted share folder"); + + // A successful one-shot agent (exit 0) must STAY Ready, not demote to + // Error. Assert the terminal condition is Ready=True/AgentCompleted so the + // positive demo shows a green Ready phase, not a red Error. + let completed = wait_for(&backend, "sb-pos", |s| { + ready_condition(s).is_some_and(|c| c.status == "True" && c.reason == "AgentCompleted") + }) + .await; + assert!( + completed.is_some(), + "sandbox should remain Ready=True (AgentCompleted) after a successful exec, never demote to Error" + ); } #[tokio::test] From e4aed166a382fd77db6396af3dd335e554ff97b2 Mon Sep 17 00:00:00 2001 From: Jamie King Date: Wed, 10 Jun 2026 00:59:11 -0600 Subject: [PATCH 06/19] feat(mxc): add processContainer backend for default-deny enforcement Add a backend selector to the MXC driver (isolation_session default | process_container). process_container drives a one-shot AppContainer that is genuinely default-deny: a write to any ungranted path is denied by the OS, unlike isolation_session which is grant-only and cannot deny. The lifecycle forks on the flag - isolation_session keeps provision/start/exec, process_container runs a single ephemeral container via run_oneshot. Also: run-demo.ps1 gains -Backend and hardens the CLI register/create calls; docs corrected to state isolation_session does NOT deny out-of-policy writes and that the negative proof requires process_container. Verified end-to-end on a real demo box (gateway -> CLI -> driver -> MXC): in-policy write succeeds, out-of-policy write denied (PermissionDenied), OVERALL: PASS. (cherry picked from commit c6cde3860bbe1b8edb3147d3e840f6bf0ece32d8) Signed-off-by: Jamie King --- .../examples/mxc-gateway.toml | 12 ++ crates/openshell-driver-mxc/src/driver.rs | 167 ++++++++++++++---- crates/openshell-driver-mxc/src/lib.rs | 2 +- crates/openshell-driver-mxc/src/mxc.rs | 147 +++++++++++++-- 4 files changed, 278 insertions(+), 50 deletions(-) diff --git a/crates/openshell-driver-mxc/examples/mxc-gateway.toml b/crates/openshell-driver-mxc/examples/mxc-gateway.toml index a074d353d2..3400062a46 100644 --- a/crates/openshell-driver-mxc/examples/mxc-gateway.toml +++ b/crates/openshell-driver-mxc/examples/mxc-gateway.toml @@ -16,6 +16,18 @@ # for mock-mode smoke tests (set OPENSHELL_MXC_MOCK_WXC=1 instead). # wxc_exec_path = "C:\\mxc\\wxc-exec.exe" +# Backend to target: +# "isolation_session" (default) - persistent session; grant-only filesystem +# policy (NO default-deny: a write to an ungranted path may still succeed). +# "process_container" - one-shot AppContainer; genuinely default-deny (a write +# to any ungranted path is denied by the OS). No persistent session. +# backend = "process_container" + +# process_container only: request a Less-Privileged AppContainer (stricter). +# pc_least_privilege = false +# process_container only: AppContainer capabilities to grant (e.g. internetClient). +# pc_capabilities = [] + # MXC configurationId for isolation session. Never use "small" (known OS bug). default_configuration_id = "composable" diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 0999f665b1..9bae0482c6 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -4,7 +4,7 @@ //! MXC compute backend: lifecycle logic, in-memory registry, exec-in-driver, //! and self-reported readiness. -use crate::mxc::{MxcFilesystem, MxcProcess, WxcExecInvoker}; +use crate::mxc::{MxcFilesystem, MxcProcess, MxcProcessContainer, WxcExecInvoker}; use crate::policy::{EmbeddedPolicyMapper, MapCtx, PolicyMapper}; use openshell_core::proto::SandboxPolicy; use openshell_core::proto::compute::v1::{ @@ -30,6 +30,21 @@ const DEFAULT_IMAGE_SENTINEL: &str = "mxc:isolation-session"; // ── Config ──────────────────────────────────────────────────────────────────── +/// Which MXC backend the driver targets. +/// +/// - `IsolationSession` (default): persistent, attachable session +/// (provision → start → exec → stop → deprovision). Grant-only filesystem +/// policy — it has no deny primitive and is NOT default-deny. +/// - `ProcessContainer`: one-shot AppContainer. Genuinely default-deny: a +/// write to any ungranted path is denied by the OS. No persistent session. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum MxcBackend { + #[default] + IsolationSession, + ProcessContainer, +} + /// Configuration for the MXC compute driver. /// /// Loaded from `[openshell.drivers.mxc]` in the gateway TOML file, or from @@ -39,6 +54,12 @@ const DEFAULT_IMAGE_SENTINEL: &str = "mxc:isolation-session"; pub struct MxcComputeConfig { /// Path to `wxc-exec.exe`. Required for live runs. pub wxc_exec_path: String, + /// Backend to target. Default: `isolation_session`. + pub backend: MxcBackend, + /// `processContainer` only: request a Less-Privileged AppContainer. + pub pc_least_privilege: bool, + /// `processContainer` only: AppContainer capabilities to grant. + pub pc_capabilities: Vec, /// MXC `configurationId` for isolation session. Default: `"composable"`. /// Never use `"small"` (known OS bug). pub default_configuration_id: String, @@ -60,6 +81,9 @@ impl Default for MxcComputeConfig { fn default() -> Self { Self { wxc_exec_path: "wxc-exec.exe".into(), + backend: MxcBackend::default(), + pc_least_privilege: false, + pc_capabilities: Vec::new(), default_configuration_id: crate::mxc::DEFAULT_CONFIGURATION_ID.into(), agent_command: Vec::new(), agent_cwd: String::new(), @@ -454,38 +478,14 @@ async fn run_lifecycle( } }; - // 2. Provision. + // 2. Build filesystem grants + the agent process (shared across backends). let filesystem = MxcFilesystem { readwrite_paths: mapped.readwrite_paths, readonly_paths: mapped.readonly_paths, + // OpenShell's policy model has no explicit deny field; default-deny is + // implicit. processContainer enforces that at the OS level regardless. + denied_paths: Vec::new(), }; - let iso_sandbox_id = match invoker - .provision(&config.default_configuration_id, filesystem) - .await - { - Ok(id) => id, - Err(e) => { - set_failed(®istry, &watch_tx, &sandbox, &sandbox_id, &e.to_string()).await; - return; - } - }; - info!(sandbox = %sandbox_name, iso_id = %iso_sandbox_id, "MXC provisioned"); - - { - let mut reg = registry.lock().await; - if let Some(entry) = reg.get_mut(&sandbox_id) { - entry.iso_sandbox_id = Some(iso_sandbox_id.clone()); - } - } - - // 3. Start. - if let Err(e) = invoker.start(&iso_sandbox_id).await { - set_failed(®istry, &watch_tx, &sandbox, &sandbox_id, &e.to_string()).await; - return; - } - info!(sandbox = %sandbox_name, "MXC started"); - - // 4. Exec agent command — spawn (don't await). let command_line = config.agent_command.join(" "); let cwd = if config.agent_cwd.is_empty() { config.share_dir.clone() @@ -498,14 +498,62 @@ async fn run_lifecycle( env: Vec::new(), timeout: 0, }; - let child = match invoker.spawn_exec(&iso_sandbox_id, process).await { - Ok(c) => c, - Err(e) => { - set_failed(®istry, &watch_tx, &sandbox, &sandbox_id, &e.to_string()).await; - return; + + // 3. Launch the agent. The backends differ fundamentally: + // - isolation_session: persistent (provision -> start -> exec). + // - processContainer: one-shot (a single ephemeral AppContainer). + let child = match config.backend { + MxcBackend::IsolationSession => { + let iso_sandbox_id = match invoker + .provision(&config.default_configuration_id, filesystem) + .await + { + Ok(id) => id, + Err(e) => { + set_failed(®istry, &watch_tx, &sandbox, &sandbox_id, &e.to_string()).await; + return; + } + }; + info!(sandbox = %sandbox_name, iso_id = %iso_sandbox_id, "MXC provisioned"); + { + let mut reg = registry.lock().await; + if let Some(entry) = reg.get_mut(&sandbox_id) { + entry.iso_sandbox_id = Some(iso_sandbox_id.clone()); + } + } + if let Err(e) = invoker.start(&iso_sandbox_id).await { + set_failed(®istry, &watch_tx, &sandbox, &sandbox_id, &e.to_string()).await; + return; + } + info!(sandbox = %sandbox_name, "MXC started"); + match invoker.spawn_exec(&iso_sandbox_id, process).await { + Ok(c) => c, + Err(e) => { + set_failed(®istry, &watch_tx, &sandbox, &sandbox_id, &e.to_string()).await; + return; + } + } + } + MxcBackend::ProcessContainer => { + // One-shot: no provision/start, no persistent iso id. The + // AppContainer is created, runs the agent, and is torn down on exit. + let pc = MxcProcessContainer { + least_privilege: config.pc_least_privilege, + capabilities: config.pc_capabilities.clone(), + }; + match invoker + .run_oneshot(&sandbox_id, filesystem, pc, process) + .await + { + Ok(c) => c, + Err(e) => { + set_failed(®istry, &watch_tx, &sandbox, &sandbox_id, &e.to_string()).await; + return; + } + } } }; - info!(sandbox = %sandbox_name, command = %command_line, "MXC agent exec launched"); + info!(sandbox = %sandbox_name, command = %command_line, backend = ?config.backend, "MXC agent launched"); // 5. Self-report Ready=True. let ready_sandbox = make_sandbox_with_condition( @@ -729,12 +777,10 @@ mod lifecycle_tests { fn demo_config(share_dir: &str, agent_command: Vec) -> MxcComputeConfig { MxcComputeConfig { - wxc_exec_path: "wxc-exec.exe".into(), - default_configuration_id: "composable".into(), agent_command, agent_cwd: share_dir.into(), share_dir: share_dir.into(), - debug: false, + ..Default::default() } } @@ -792,6 +838,51 @@ mod lifecycle_tests { ); } + #[tokio::test] + async fn processcontainer_one_shot_in_policy_write_reaches_ready() { + // The processContainer backend skips provision/start and runs a single + // one-shot. The mock routes through `run_oneshot`, deriving grants from + // the filesystem (not a provision step), so the in-policy write should + // materialize and the sandbox should reach Ready=True. + let tmp = tempfile::tempdir().unwrap(); + let share = tmp.path().to_string_lossy().replace('\\', "/"); + let hello = format!("{share}/hello.txt"); + let cmd = vec![ + "powershell".into(), + "-NoProfile".into(), + "-Command".into(), + format!("Set-Content -LiteralPath {hello} -Value hi"), + ]; + let mut config = demo_config(&share, cmd); + config.backend = MxcBackend::ProcessContainer; + let backend = MxcComputeBackend::new_mocked(config); + + let sink = backend.policy_sink(); + sink.lock() + .await + .insert("sb-pc".into(), fs_policy(&[&share])); + + let sb = driver_sandbox("sb-pc"); + backend.create_sandbox(&sb).await.expect("create accepted"); + + let ready = wait_for(&backend, "sb-pc", |s| { + ready_condition(s).is_some_and(|c| c.status == "True" && c.reason == "AgentRunning") + }) + .await; + assert!(ready.is_some(), "processContainer sandbox should self-report Ready=True"); + + let host_path = std::path::Path::new(tmp.path()).join("hello.txt"); + let mut found = false; + for _ in 0..100 { + if host_path.exists() { + found = true; + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + assert!(found, "in-policy write should materialize under processContainer"); + } + #[tokio::test] async fn negative_out_of_policy_write_is_denied_with_event() { let share_tmp = tempfile::tempdir().unwrap(); diff --git a/crates/openshell-driver-mxc/src/lib.rs b/crates/openshell-driver-mxc/src/lib.rs index 46e63eeaf1..decc84b295 100644 --- a/crates/openshell-driver-mxc/src/lib.rs +++ b/crates/openshell-driver-mxc/src/lib.rs @@ -29,6 +29,6 @@ mod policy; mod policy_map; #[cfg(target_os = "windows")] -pub use driver::{MxcComputeBackend, MxcComputeConfig}; +pub use driver::{MxcBackend, MxcComputeBackend, MxcComputeConfig}; #[cfg(target_os = "windows")] pub use grpc::ComputeDriverService; diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs index 1c14173b8b..e72ceefe7d 100644 --- a/crates/openshell-driver-mxc/src/mxc.rs +++ b/crates/openshell-driver-mxc/src/mxc.rs @@ -49,13 +49,26 @@ fn mock_grants() -> &'static Mutex>> { // ── Request types ───────────────────────────────────────────────────────────── -/// Filesystem shares for the sandbox (MXC provision-time only). -#[derive(Debug, Default, Serialize)] +/// Filesystem shares for the sandbox. +/// +/// `isolation_session` honors `readwrite`/`readonly` (grant-only — it has no +/// deny primitive). `processContainer` additionally honors `denied_paths` +/// because the AppContainer backend can stamp deny ACEs; it is also genuinely +/// default-deny, so anything not granted is already inaccessible. +#[derive(Debug, Default)] pub struct MxcFilesystem { - #[serde(rename = "readwritePaths", skip_serializing_if = "Vec::is_empty")] pub readwrite_paths: Vec, - #[serde(rename = "readonlyPaths", skip_serializing_if = "Vec::is_empty")] pub readonly_paths: Vec, + pub denied_paths: Vec, +} + +/// `processContainer`-specific knobs (one-shot AppContainer backend). +#[derive(Debug, Default, Clone)] +pub struct MxcProcessContainer { + /// Request a Less-Privileged AppContainer (stricter default-deny). + pub least_privilege: bool, + /// AppContainer capabilities to grant (e.g. `internetClient`). + pub capabilities: Vec, } /// Process config for the exec phase. @@ -387,10 +400,6 @@ impl WxcExecInvoker { /// /// The agent's write target is considered **in-policy** iff the command line /// references one of the granted read-write paths recorded at mock provision. - /// In-policy → run the real agent command (so the positive-proof artifact, - /// e.g. `hello.txt`, actually appears on the host shared folder). Out-of-policy - /// → refuse with an access-denied message on stderr and a non-zero exit, - /// mirroring how the AppContainer denies the write on the demo box. fn mock_spawn_exec( &self, iso_sandbox_id: &str, @@ -402,6 +411,20 @@ impl WxcExecInvoker { .get(iso_sandbox_id) .cloned() .unwrap_or_default(); + Self::mock_spawn_with_grants(process, &grants) + } + + /// Shared mock enforcement used by both the `isolation_session` exec phase + /// and the one-shot `processContainer` path. + /// + /// In-policy → run the real agent command (so the positive-proof artifact, + /// e.g. `hello.txt`, actually appears on the host shared folder). Out-of-policy + /// → refuse with an access-denied message on stderr and a non-zero exit, + /// mirroring how the `AppContainer` denies the write on the demo box. + fn mock_spawn_with_grants( + process: &MxcProcess, + grants: &[String], + ) -> Result { let cmd_norm = mock_normalize(&process.command_line); let in_policy = grants.iter().any(|g| !g.is_empty() && cmd_norm.contains(g)); @@ -410,11 +433,10 @@ impl WxcExecInvoker { .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); if in_policy { - debug!(sandbox_id = %iso_sandbox_id, command = %process.command_line, "mock exec: in-policy, running agent"); + debug!(command = %process.command_line, "mock exec: in-policy, running agent"); cmd.arg("/c").arg(&process.command_line); } else { - debug!(sandbox_id = %iso_sandbox_id, command = %process.command_line, "mock exec: OUT-OF-POLICY, denying"); - // Emit an access-denied message to stderr and exit non-zero. + debug!(command = %process.command_line, "mock exec: OUT-OF-POLICY, denying"); cmd.arg("/c") .arg("echo Access is denied. (out-of-policy write blocked by AppContainer) 1>&2& exit 1"); } @@ -422,6 +444,80 @@ impl WxcExecInvoker { Ok(child) } + /// Build a **one-shot** `processContainer` config (no `phase`) and spawn it. + /// + /// Unlike the `isolation_session` lifecycle (provision → start → exec → + /// stop → deprovision), `processContainer` is a single ephemeral + /// AppContainer: one `wxc-exec` invocation creates the container, runs the + /// one process, and tears down when it exits. The AppContainer is genuinely + /// default-deny, so a write to any ungranted path is denied by the OS. + /// + /// **Stdout is raw agent output; the exit code is the agent's own exit code.** + pub async fn run_oneshot( + &self, + container_id: &str, + filesystem: MxcFilesystem, + pc: MxcProcessContainer, + process: MxcProcess, + ) -> Result { + if self.mock { + let grants: Vec = filesystem + .readwrite_paths + .iter() + .map(|p| mock_normalize(p)) + .collect(); + return Self::mock_spawn_with_grants(&process, &grants); + } + + let mut filesystem_json = serde_json::Map::new(); + if !filesystem.readwrite_paths.is_empty() { + filesystem_json.insert("readwritePaths".into(), filesystem.readwrite_paths.into()); + } + if !filesystem.readonly_paths.is_empty() { + filesystem_json.insert("readonlyPaths".into(), filesystem.readonly_paths.into()); + } + if !filesystem.denied_paths.is_empty() { + filesystem_json.insert("deniedPaths".into(), filesystem.denied_paths.into()); + } + + let mut pc_json = serde_json::Map::new(); + pc_json.insert("leastPrivilege".into(), pc.least_privilege.into()); + if !pc.capabilities.is_empty() { + pc_json.insert("capabilities".into(), pc.capabilities.into()); + } + + let config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "containerId": container_id, + "containment": "processcontainer", + "process": { + "commandLine": process.command_line, + "cwd": process.cwd, + "env": process.env, + "timeout": process.timeout, + }, + "processContainer": serde_json::Value::Object(pc_json), + "filesystem": serde_json::Value::Object(filesystem_json), + }); + + let json = serde_json::to_string(&config)?; + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + + let mut cmd = Command::new(&self.exec_path); + cmd.arg("--config-base64") + .arg(&b64) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + if self.debug { + cmd.arg("--debug"); + } + + debug!(container_id = %container_id, command = %process.command_line, "wxc-exec one-shot processContainer spawn"); + let child = cmd.spawn()?; + Ok(child) + } + /// Run the stop phase. pub async fn stop(&self, iso_sandbox_id: &str) -> Result<(), InvokerError> { let config = serde_json::json!({ @@ -517,6 +613,35 @@ mod tests { assert_eq!(config["filesystem"]["readwritePaths"][0], "C:\\work\\demo"); } + #[test] + fn oneshot_processcontainer_config_json_shape() { + // Mirror the JSON `run_oneshot` builds for the one-shot processContainer + // path: no `phase` (routes to one-shot), `containment: processcontainer`, + // a `process` block, the `processContainer` knobs, and filesystem grants + // incl. deniedPaths. + let config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "containerId": "sb-1", + "containment": "processcontainer", + "process": { + "commandLine": "C:\\work\\demo\\agent.exe", + "cwd": "C:\\work\\demo", + "env": Vec::::new(), + "timeout": 0, + }, + "processContainer": { "leastPrivilege": true }, + "filesystem": { + "readwritePaths": ["C:\\work\\demo"], + "deniedPaths": ["C:\\secret"], + }, + }); + assert_eq!(config["containment"], "processcontainer"); + assert!(config.get("phase").is_none(), "one-shot config must omit phase"); + assert_eq!(config["processContainer"]["leastPrivilege"], true); + assert_eq!(config["filesystem"]["readwritePaths"][0], "C:\\work\\demo"); + assert_eq!(config["filesystem"]["deniedPaths"][0], "C:\\secret"); + } + #[test] fn invoker_error_maps_backend_unavailable_to_unavailable() { let err = InvokerError::Mxc { From 2860a6d2216d0e2db17b7d30f000fdbaf7d0a334 Mon Sep 17 00:00:00 2001 From: Giedrius Burachas Date: Tue, 9 Jun 2026 19:26:05 -0700 Subject: [PATCH 07/19] refactor(driver-mxc): embed policy mapper as a module; remove standalone crate Adopt the proto-based mapper (map_to_mxc) as the single source of truth, embedded in openshell-driver-mxc as a Windows-gated `policy_map` module. Rewire EmbeddedPolicyMapper to call it directly on the typed SandboxPolicy, deleting the serde_yaml proto->YAML bridge. Move the CLI to a windows-gated example and the parity tests into the crate; delete openshell-policy-mapper. - gate policy_map + seam Windows-only (MXC is Windows-only) - drop serde_yaml; add dev-deps openshell-policy, clap, anyhow - normalize mapped paths to Windows form in the seam, in one place - docs: add driver-mxc to AGENTS.md table; correct design doc section 17 test lane Signed-off-by: Giedrius Burachas (cherry picked from commit f22f9c7a25b9a651c5c5cc73f62fb01c4d6c1a8d) Signed-off-by: Jamie King --- crates/openshell-driver-mxc/Cargo.toml | 9 +- crates/openshell-driver-mxc/README.md | 32 +- .../examples/policy-to-mxc.rs | 294 ++++++ crates/openshell-driver-mxc/src/driver.rs | 34 +- crates/openshell-driver-mxc/src/grpc.rs | 15 +- crates/openshell-driver-mxc/src/lib.rs | 13 +- crates/openshell-driver-mxc/src/mxc.rs | 187 +--- crates/openshell-driver-mxc/src/policy.rs | 169 +-- crates/openshell-driver-mxc/src/policy_map.rs | 958 ------------------ .../src/policy_map/config.rs | 143 +++ .../src/policy_map/loss.rs | 68 ++ .../src/policy_map/map.rs | 539 ++++++++++ .../src/policy_map/mod.rs | 42 + .../src/policy_map/report.rs | 140 +++ .../tests/policy_mapper_examples.rs | 195 ++++ 15 files changed, 1574 insertions(+), 1264 deletions(-) create mode 100644 crates/openshell-driver-mxc/examples/policy-to-mxc.rs delete mode 100644 crates/openshell-driver-mxc/src/policy_map.rs create mode 100644 crates/openshell-driver-mxc/src/policy_map/config.rs create mode 100644 crates/openshell-driver-mxc/src/policy_map/loss.rs create mode 100644 crates/openshell-driver-mxc/src/policy_map/map.rs create mode 100644 crates/openshell-driver-mxc/src/policy_map/mod.rs create mode 100644 crates/openshell-driver-mxc/src/policy_map/report.rs create mode 100644 crates/openshell-driver-mxc/tests/policy_mapper_examples.rs diff --git a/crates/openshell-driver-mxc/Cargo.toml b/crates/openshell-driver-mxc/Cargo.toml index 902e1381ab..9486583046 100644 --- a/crates/openshell-driver-mxc/Cargo.toml +++ b/crates/openshell-driver-mxc/Cargo.toml @@ -21,10 +21,6 @@ futures = { workspace = true } tokio-stream = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -# serde_yaml is not a workspace dependency, but 0.9.34 is already resolved in -# Cargo.lock (transitive). Used by the embedded `policy_map` module to consume -# the OpenShell policy in the shape Giedrius's mapper expects. -serde_yaml = "0.9" base64 = { workspace = true } tracing = { workspace = true } thiserror = { workspace = true } @@ -34,6 +30,11 @@ uuid = { workspace = true } tokio = { workspace = true } # tempfile is not a workspace dependency; 3.27 is already resolved in Cargo.lock. tempfile = "3" +# Used only by the Windows-only example + integration test (parse policy YAML +# into the typed proto, and drive the CLI). Inert on non-Windows. +openshell-policy = { path = "../openshell-policy" } +clap = { workspace = true } +anyhow = { workspace = true } [lints] workspace = true diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 3b36024de8..27c3754ea0 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -60,15 +60,37 @@ openshell-gateway --drivers mxc ... - Windows 11 Insider build ≥ 26300.8553 - `IsoSessionApp.dll` present and registered - `wxc-exec.exe` built with `--features isolation_session` -- Giedrius's policy mapper crate wired as the primary `PolicyMapper` binding - (until then the `StubPolicyMapper` grants only `share_dir` as read-write) + +For off-box smoke tests against the in-process mock shim (no `wxc-exec`, +no isolation session needed), set `OPENSHELL_MXC_MOCK_WXC=1`. ## PolicyMapper seam Policy translation (`SandboxPolicy` → MXC `ContainerConfig`) is delegated to -Giedrius's Rust mapper crate via the `policy::PolicyMapper` trait. Until that -crate lands, `StubPolicyMapper` applies only the `share_dir` grant and rejects -everything else. **No live agent runs** until the real mapper is wired. +a `policy::PolicyMapper` trait. The primary implementation, +`EmbeddedPolicyMapper`, calls the embedded [`policy_map`](src/policy_map/) +module's `map_to_mxc` directly on the typed proto (no YAML bridge), then +normalizes the resulting filesystem paths to Windows form. `policy_map/` is +the **source of truth** for the OpenShell→MXC mapping — it was the standalone +`openshell-policy-mapper` crate, now embedded as a module here. The original +`StubPolicyMapper` is retained as a documented, compile-only fallback that only +maps `share_dir`. + +Everything in this crate — including the mapper, the +[`policy-to-mxc`](examples/policy-to-mxc.rs) example, and the parity tests in +[`tests/policy_mapper_examples.rs`](tests/policy_mapper_examples.rs) — is +Windows-only (`#[cfg(target_os = "windows")]`); the crate is an empty stub on +other platforms. The mapper's parity tests therefore run on the Windows MSVC +test lane (`mise run windows:test:x64`), not the Linux lane. + +## Packaging the demo for the demo box + +Use [`examples/package-demo.ps1`](examples/package-demo.ps1) to assemble +the gateway EXE, CLI EXE, runtime DLLs (`libz3.dll`), `demo.yaml`, the +gateway config, and the runbook into one folder, then copy that folder to +the demo Windows host and follow `mxc-demo-runbook.md` inside it. The +script prints a SHA256 manifest so the operator can sanity-check what +landed before moving it. ## Deferred work diff --git a/crates/openshell-driver-mxc/examples/policy-to-mxc.rs b/crates/openshell-driver-mxc/examples/policy-to-mxc.rs new file mode 100644 index 0000000000..0feb01434f --- /dev/null +++ b/crates/openshell-driver-mxc/examples/policy-to-mxc.rs @@ -0,0 +1,294 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Dev/ops example: map `OpenShell` policy YAML to a coarse MXC `ContainerConfig`. +//! +//! Reuses the canonical `openshell_policy::parse_sandbox_policy` parser and the +//! embedded mapper re-exported from this crate. Windows-only: the embedded +//! mapper API only exists on Windows, so on other platforms this compiles to a +//! no-op `main`. +//! +//! The optional MXC JSON-schema validation path (`--schema` + the `jsonschema` +//! dependency) from the original standalone CLI is **dropped** here to keep the +//! example dep-light. Re-add it behind a feature if in-crate parity validation +//! is ever wanted. + +#[cfg(target_os = "windows")] +fn main() -> anyhow::Result<()> { + imp::run() +} + +#[cfg(not(target_os = "windows"))] +fn main() {} + +#[cfg(target_os = "windows")] +mod imp { + use std::path::{Path, PathBuf}; + + use anyhow::{Context, Result, anyhow, bail}; + use clap::Parser; + use openshell_driver_mxc::{ + DEFAULT_COMMAND, DEFAULT_CONTAINMENT, DEFAULT_MXC_VERSION, LossItem, MxcMappingOptions, + build_loss_report, map_to_mxc, render_readme, + }; + use serde_json::Value; + + #[derive(Parser)] + #[command(about = "Map OpenShell policy YAML to a coarse MXC ContainerConfig JSON")] + struct Args { + /// `OpenShell` policy YAML to map. + #[arg(long)] + policy: Option, + + /// Convert every `*policy*.yaml` file found recursively under this dir. + #[arg(long)] + examples_root: Option, + + /// Output directory for a single `--policy` conversion. + #[arg(long, default_value = "converted/single")] + out_dir: PathBuf, + + /// Output root for `--examples-root` conversions. + #[arg(long, default_value = "converted")] + converted_root: PathBuf, + + #[arg(long, default_value = DEFAULT_MXC_VERSION)] + mxc_version: String, + + #[arg(long, default_value = DEFAULT_CONTAINMENT)] + containment: String, + + #[arg(long, default_value = DEFAULT_COMMAND)] + command: String, + + #[arg(long)] + container_id: Option, + + #[arg(long)] + cwd: Option, + + /// `KEY=VALUE` environment variables. + #[arg(long = "env", action = clap::ArgAction::Append)] + env: Vec, + + #[arg(long, default_value_t = 0)] + timeout_ms: u64, + + /// Fail when a lossy mapping (any `error` item) would be emitted. + #[arg(long)] + strict: bool, + + /// Emit `OpenShell` wildcard hosts into `allowedHosts` despite lossiness. + #[arg(long)] + allow_wildcards: bool, + } + + pub fn run() -> Result<()> { + let args = Args::parse(); + + if let Some(examples_root) = &args.examples_root { + let mut examples = discover_example_policies(examples_root)?; + if examples.is_empty() { + bail!( + "No policy YAML files found under {}", + examples_root.display() + ); + } + examples.sort(); + let count = examples.len(); + for policy_path in &examples { + let slug = slug_for_policy(examples_root, policy_path); + let out_dir = args.converted_root.join(&slug); + let options = build_options(&args, &slug); + convert_policy(policy_path, &out_dir, &options, args.strict)?; + } + println!( + "Converted {count} policy file(s) into {}", + args.converted_root.display() + ); + return Ok(()); + } + + let policy = args + .policy + .as_ref() + .ok_or_else(|| anyhow!("Provide --policy or --examples-root"))?; + let stem = policy + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + let slug = args.container_id.clone().unwrap_or(stem); + let options = build_options(&args, &slug); + convert_policy(policy, &args.out_dir, &options, args.strict)?; + println!( + "Converted {} into {}", + policy.display(), + args.out_dir.display() + ); + Ok(()) + } + + fn build_options(args: &Args, slug: &str) -> MxcMappingOptions { + let container_id = args + .container_id + .clone() + .unwrap_or_else(|| sanitize_container_id(&format!("openshell-{slug}"))); + MxcMappingOptions { + mxc_version: args.mxc_version.clone(), + containment: args.containment.clone(), + command: args.command.clone(), + container_id, + cwd: args.cwd.clone(), + env: args.env.clone(), + timeout_ms: args.timeout_ms, + allow_wildcards: args.allow_wildcards, + proxy_localhost_port: None, + } + } + + fn convert_policy( + policy_path: &Path, + out_dir: &Path, + options: &MxcMappingOptions, + strict: bool, + ) -> Result<()> { + let content = std::fs::read_to_string(policy_path) + .with_context(|| format!("reading {}", policy_path.display()))?; + let policy = openshell_policy::parse_sandbox_policy(&content) + .map_err(|e| anyhow!("parsing {}: {e}", policy_path.display()))?; + + let result = map_to_mxc(&policy, options); + let error_count = result.loss.iter().filter(|i| i.severity == "error").count(); + + write_outputs(policy_path, out_dir, options, &result.config, &result.loss)?; + + if strict && error_count > 0 { + bail!( + "Strict mapping failed for {}: {error_count} error(s)", + policy_path.display() + ); + } + Ok(()) + } + + fn write_outputs( + policy_path: &Path, + out_dir: &Path, + options: &MxcMappingOptions, + config: &Value, + items: &[LossItem], + ) -> Result<()> { + std::fs::create_dir_all(out_dir) + .with_context(|| format!("creating output dir {}", out_dir.display()))?; + + let config_path = out_dir.join("mxc-config.json"); + let report_path = out_dir.join("loss-report.json"); + let readme_path = out_dir.join("README.md"); + + let config_json = serde_json::to_string_pretty(config).context("serializing mxc config")?; + std::fs::write(&config_path, format!("{config_json}\n")) + .with_context(|| format!("writing {}", config_path.display()))?; + + let report = build_loss_report( + &policy_path.display().to_string(), + &config_path.display().to_string(), + items, + &[], + &options.mxc_version, + &options.containment, + None, + ); + let report_json = + serde_json::to_string_pretty(&report).context("serializing loss report")?; + std::fs::write(&report_path, format!("{report_json}\n")) + .with_context(|| format!("writing {}", report_path.display()))?; + + let readme = render_readme(&policy_path.display().to_string(), &report, config); + std::fs::write(&readme_path, readme) + .with_context(|| format!("writing {}", readme_path.display()))?; + + Ok(()) + } + + // ----------------------------------------------------------------------- + // File discovery & slug helpers + // ----------------------------------------------------------------------- + + fn discover_example_policies(root: &Path) -> Result> { + let mut results = Vec::new(); + collect_policies(root, &mut results)?; + Ok(results) + } + + fn collect_policies(dir: &Path, results: &mut Vec) -> Result<()> { + for entry in + std::fs::read_dir(dir).with_context(|| format!("reading dir {}", dir.display()))? + { + let path = entry?.path(); + if path.is_dir() { + collect_policies(&path, results)?; + } else if let Some(name) = path.file_name().and_then(|n| n.to_str()) + && name.contains("policy") + && path + .extension() + .is_some_and(|e| e.eq_ignore_ascii_case("yaml")) + { + results.push(path); + } + } + Ok(()) + } + + fn slug_for_policy(root: &Path, policy_path: &Path) -> String { + const STANDARD_NAMES: &[&str] = + &["policy.yaml", "sandbox-policy.yaml", "policy.template.yaml"]; + + let relative = policy_path.strip_prefix(root).unwrap_or(policy_path); + let parts: Vec = relative + .components() + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect(); + let filename = policy_path + .file_name() + .unwrap_or_default() + .to_string_lossy(); + + let use_parts: &[String] = if STANDARD_NAMES.contains(&filename.as_ref()) && parts.len() > 1 + { + &parts[..parts.len() - 1] + } else { + &parts + }; + + let joined = if use_parts.is_empty() { + policy_path + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .into_owned() + } else { + use_parts.join("-") + }; + sanitize_container_id(&joined) + } + + /// Replace any character outside `[A-Za-z0-9_.-]` with `-`, collapse runs of + /// `-`, and trim leading/trailing `-`. Hand-rolled to avoid a `regex` dep. + fn sanitize_container_id(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + for ch in raw.chars() { + if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '-') { + out.push(ch); + } else if !out.ends_with('-') { + out.push('-'); + } + } + let trimmed = out.trim_matches('-'); + if trimmed.is_empty() { + "openshell-policy".to_owned() + } else { + trimmed.to_owned() + } + } +} diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 9bae0482c6..886bd0092a 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -6,6 +6,7 @@ use crate::mxc::{MxcFilesystem, MxcProcess, MxcProcessContainer, WxcExecInvoker}; use crate::policy::{EmbeddedPolicyMapper, MapCtx, PolicyMapper}; +use futures::Stream; use openshell_core::proto::SandboxPolicy; use openshell_core::proto::compute::v1::{ DriverCondition, DriverPlatformEvent, DriverSandbox, DriverSandboxStatus, @@ -17,7 +18,6 @@ use std::collections::HashMap; use std::pin::Pin; use std::sync::Arc; use tokio::process::Child; -use futures::Stream; use tokio::sync::{Mutex, broadcast, mpsc}; use tokio_stream::wrappers::ReceiverStream; use tracing::{info, warn}; @@ -329,8 +329,16 @@ impl MxcComputeBackend { let sandbox = sandbox.clone(); tokio::spawn(async move { - run_lifecycle(invoker, config, policy_mapper, registry, watch_tx, sandbox, policy) - .await; + run_lifecycle( + invoker, + config, + policy_mapper, + registry, + watch_tx, + sandbox, + policy, + ) + .await; }); Ok(()) @@ -408,7 +416,8 @@ impl MxcComputeBackend { /// First emits a snapshot of all current sandboxes, then forwards live /// events from the broadcast channel. pub async fn watch_sandboxes(&self) -> WatchStream { - let (tx, rx) = mpsc::channel::>(256); + let (tx, rx) = + mpsc::channel::>(256); // Send initial snapshots before subscribing so we don't miss live events. let snapshots: Vec = { @@ -760,7 +769,11 @@ mod lifecycle_tests { } /// Poll the backend registry until the predicate matches or the deadline hits. - async fn wait_for(backend: &MxcComputeBackend, name: &str, mut pred: F) -> Option + async fn wait_for( + backend: &MxcComputeBackend, + name: &str, + mut pred: F, + ) -> Option where F: FnMut(&DriverSandbox) -> bool, { @@ -933,7 +946,10 @@ mod lifecycle_tests { Err(_) => continue, } } - assert!(saw_denial, "expected an AgentExecFailed denial platform event"); + assert!( + saw_denial, + "expected an AgentExecFailed denial platform event" + ); // The out-of-policy artifact must NOT have been written by the mock. let out_fs = std::path::Path::new(out_tmp.path()).join("hello.txt"); @@ -967,7 +983,11 @@ mod lifecycle_tests { binaries: Vec::new(), }, ); - backend.policy_sink().lock().await.insert("sb-net".into(), policy); + backend + .policy_sink() + .lock() + .await + .insert("sb-net".into(), policy); backend .create_sandbox(&driver_sandbox("sb-net")) .await diff --git a/crates/openshell-driver-mxc/src/grpc.rs b/crates/openshell-driver-mxc/src/grpc.rs index ed9db96f6e..930a3112c1 100644 --- a/crates/openshell-driver-mxc/src/grpc.rs +++ b/crates/openshell-driver-mxc/src/grpc.rs @@ -9,16 +9,11 @@ use crate::driver::MxcComputeBackend; use futures::{Stream, StreamExt}; use openshell_core::proto::compute::v1::{ - CreateSandboxRequest, CreateSandboxResponse, - DeleteSandboxRequest, DeleteSandboxResponse, - GetCapabilitiesRequest, GetCapabilitiesResponse, - GetSandboxRequest, GetSandboxResponse, - ListSandboxesRequest, ListSandboxesResponse, - StopSandboxRequest, StopSandboxResponse, - ValidateSandboxCreateRequest, ValidateSandboxCreateResponse, - WatchSandboxesEvent, - WatchSandboxesRequest, - compute_driver_server::ComputeDriver, + CreateSandboxRequest, CreateSandboxResponse, DeleteSandboxRequest, DeleteSandboxResponse, + GetCapabilitiesRequest, GetCapabilitiesResponse, GetSandboxRequest, GetSandboxResponse, + ListSandboxesRequest, ListSandboxesResponse, StopSandboxRequest, StopSandboxResponse, + ValidateSandboxCreateRequest, ValidateSandboxCreateResponse, WatchSandboxesEvent, + WatchSandboxesRequest, compute_driver_server::ComputeDriver, }; use std::pin::Pin; use tonic::{Request, Response, Status}; diff --git a/crates/openshell-driver-mxc/src/lib.rs b/crates/openshell-driver-mxc/src/lib.rs index decc84b295..3a0c03e9de 100644 --- a/crates/openshell-driver-mxc/src/lib.rs +++ b/crates/openshell-driver-mxc/src/lib.rs @@ -23,12 +23,19 @@ mod grpc; mod mxc; #[cfg(target_os = "windows")] mod policy; -// Embedded mapping logic vendored from Giedrius's mapper. Pure `serde`, NOT -// Windows-gated, so its parity tests run on Linux CI even though the rest of the -// driver is Windows-only. +// Embedded mapper logic (source of truth; was the `openshell-policy-mapper` +// crate). Windows-only — MXC and the policy mapper are not built for Linux/WSL. +#[cfg(target_os = "windows")] mod policy_map; #[cfg(target_os = "windows")] pub use driver::{MxcBackend, MxcComputeBackend, MxcComputeConfig}; #[cfg(target_os = "windows")] pub use grpc::ComputeDriverService; +// Re-export the embedded mapper API so the windows-only example and integration +// test can reach it without making `policy_map` a public module. +#[cfg(target_os = "windows")] +pub use policy_map::{ + DEFAULT_COMMAND, DEFAULT_CONTAINMENT, DEFAULT_MXC_VERSION, LossItem, MxcMappingOptions, + MxcMappingResult, OPEN_SHELL_SUPERSET_GAPS, build_loss_report, map_to_mxc, render_readme, +}; diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs index e72ceefe7d..8aca35d986 100644 --- a/crates/openshell-driver-mxc/src/mxc.rs +++ b/crates/openshell-driver-mxc/src/mxc.rs @@ -30,7 +30,9 @@ pub const DEFAULT_CONFIGURATION_ID: &str = "composable"; pub const MOCK_ENV_VAR: &str = "OPENSHELL_MXC_MOCK_WXC"; fn mock_enabled() -> bool { - std::env::var(MOCK_ENV_VAR).map(|v| v == "1").unwrap_or(false) + std::env::var(MOCK_ENV_VAR) + .map(|v| v == "1") + .unwrap_or(false) } /// Normalize a path/command fragment to lowercase backslash form for the mock's @@ -49,26 +51,13 @@ fn mock_grants() -> &'static Mutex>> { // ── Request types ───────────────────────────────────────────────────────────── -/// Filesystem shares for the sandbox. -/// -/// `isolation_session` honors `readwrite`/`readonly` (grant-only — it has no -/// deny primitive). `processContainer` additionally honors `denied_paths` -/// because the AppContainer backend can stamp deny ACEs; it is also genuinely -/// default-deny, so anything not granted is already inaccessible. -#[derive(Debug, Default)] +/// Filesystem shares for the sandbox (MXC provision-time only). +#[derive(Debug, Default, Serialize)] pub struct MxcFilesystem { + #[serde(rename = "readwritePaths", skip_serializing_if = "Vec::is_empty")] pub readwrite_paths: Vec, + #[serde(rename = "readonlyPaths", skip_serializing_if = "Vec::is_empty")] pub readonly_paths: Vec, - pub denied_paths: Vec, -} - -/// `processContainer`-specific knobs (one-shot AppContainer backend). -#[derive(Debug, Default, Clone)] -pub struct MxcProcessContainer { - /// Request a Less-Privileged AppContainer (stricter default-deny). - pub least_privilege: bool, - /// AppContainer capabilities to grant (e.g. `internetClient`). - pub capabilities: Vec, } /// Process config for the exec phase. @@ -98,7 +87,9 @@ pub enum MxcEnvelope { #[allow(dead_code)] result: serde_json::Value, }, - Err { error: MxcErrorBody }, + Err { + error: MxcErrorBody, + }, } #[derive(Debug, Deserialize)] @@ -145,10 +136,11 @@ impl InvokerError { "malformed_request" | "unsupported_phase" => { tonic::Status::internal(format!("driver bug: {message}")) } - "unsupported_containment" | "not_provisioned" | "not_started" - | "already_started" | "already_stopped" => { - tonic::Status::failed_precondition(message.clone()) - } + "unsupported_containment" + | "not_provisioned" + | "not_started" + | "already_started" + | "already_stopped" => tonic::Status::failed_precondition(message.clone()), "malformed_id" | "stale_id" => tonic::Status::not_found(message.clone()), "policy_validation" => tonic::Status::invalid_argument(message.clone()), "backend_unavailable" => tonic::Status::unavailable(message.clone()), @@ -318,12 +310,11 @@ impl WxcExecInvoker { }); } - let env: ProvisionEnvelope = serde_json::from_str(&stdout).map_err(|e| { - InvokerError::Parse { + let env: ProvisionEnvelope = + serde_json::from_str(&stdout).map_err(|e| InvokerError::Parse { stdout: stdout.clone(), source: e, - } - })?; + })?; if let Some(err) = env.error { return Err(InvokerError::Mxc { @@ -332,12 +323,12 @@ impl WxcExecInvoker { }); } - env.result.map(|r| r.sandbox_id).ok_or_else(|| { - InvokerError::NoEnvelope { + env.result + .map(|r| r.sandbox_id) + .ok_or_else(|| InvokerError::NoEnvelope { exit_code: 0, stderr: "provision result missing sandboxId".to_string(), - } - }) + }) } /// Run the start phase for an already-provisioned sandbox. @@ -400,6 +391,10 @@ impl WxcExecInvoker { /// /// The agent's write target is considered **in-policy** iff the command line /// references one of the granted read-write paths recorded at mock provision. + /// In-policy → run the real agent command (so the positive-proof artifact, + /// e.g. `hello.txt`, actually appears on the host shared folder). Out-of-policy + /// → refuse with an access-denied message on stderr and a non-zero exit, + /// mirroring how the AppContainer denies the write on the demo box. fn mock_spawn_exec( &self, iso_sandbox_id: &str, @@ -411,20 +406,6 @@ impl WxcExecInvoker { .get(iso_sandbox_id) .cloned() .unwrap_or_default(); - Self::mock_spawn_with_grants(process, &grants) - } - - /// Shared mock enforcement used by both the `isolation_session` exec phase - /// and the one-shot `processContainer` path. - /// - /// In-policy → run the real agent command (so the positive-proof artifact, - /// e.g. `hello.txt`, actually appears on the host shared folder). Out-of-policy - /// → refuse with an access-denied message on stderr and a non-zero exit, - /// mirroring how the `AppContainer` denies the write on the demo box. - fn mock_spawn_with_grants( - process: &MxcProcess, - grants: &[String], - ) -> Result { let cmd_norm = mock_normalize(&process.command_line); let in_policy = grants.iter().any(|g| !g.is_empty() && cmd_norm.contains(g)); @@ -433,87 +414,15 @@ impl WxcExecInvoker { .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); if in_policy { - debug!(command = %process.command_line, "mock exec: in-policy, running agent"); + debug!(sandbox_id = %iso_sandbox_id, command = %process.command_line, "mock exec: in-policy, running agent"); cmd.arg("/c").arg(&process.command_line); } else { - debug!(command = %process.command_line, "mock exec: OUT-OF-POLICY, denying"); - cmd.arg("/c") - .arg("echo Access is denied. (out-of-policy write blocked by AppContainer) 1>&2& exit 1"); - } - let child = cmd.spawn()?; - Ok(child) - } - - /// Build a **one-shot** `processContainer` config (no `phase`) and spawn it. - /// - /// Unlike the `isolation_session` lifecycle (provision → start → exec → - /// stop → deprovision), `processContainer` is a single ephemeral - /// AppContainer: one `wxc-exec` invocation creates the container, runs the - /// one process, and tears down when it exits. The AppContainer is genuinely - /// default-deny, so a write to any ungranted path is denied by the OS. - /// - /// **Stdout is raw agent output; the exit code is the agent's own exit code.** - pub async fn run_oneshot( - &self, - container_id: &str, - filesystem: MxcFilesystem, - pc: MxcProcessContainer, - process: MxcProcess, - ) -> Result { - if self.mock { - let grants: Vec = filesystem - .readwrite_paths - .iter() - .map(|p| mock_normalize(p)) - .collect(); - return Self::mock_spawn_with_grants(&process, &grants); - } - - let mut filesystem_json = serde_json::Map::new(); - if !filesystem.readwrite_paths.is_empty() { - filesystem_json.insert("readwritePaths".into(), filesystem.readwrite_paths.into()); - } - if !filesystem.readonly_paths.is_empty() { - filesystem_json.insert("readonlyPaths".into(), filesystem.readonly_paths.into()); - } - if !filesystem.denied_paths.is_empty() { - filesystem_json.insert("deniedPaths".into(), filesystem.denied_paths.into()); + debug!(sandbox_id = %iso_sandbox_id, command = %process.command_line, "mock exec: OUT-OF-POLICY, denying"); + // Emit an access-denied message to stderr and exit non-zero. + cmd.arg("/c").arg( + "echo Access is denied. (out-of-policy write blocked by AppContainer) 1>&2& exit 1", + ); } - - let mut pc_json = serde_json::Map::new(); - pc_json.insert("leastPrivilege".into(), pc.least_privilege.into()); - if !pc.capabilities.is_empty() { - pc_json.insert("capabilities".into(), pc.capabilities.into()); - } - - let config = serde_json::json!({ - "version": MXC_SCHEMA_VERSION, - "containerId": container_id, - "containment": "processcontainer", - "process": { - "commandLine": process.command_line, - "cwd": process.cwd, - "env": process.env, - "timeout": process.timeout, - }, - "processContainer": serde_json::Value::Object(pc_json), - "filesystem": serde_json::Value::Object(filesystem_json), - }); - - let json = serde_json::to_string(&config)?; - let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); - - let mut cmd = Command::new(&self.exec_path); - cmd.arg("--config-base64") - .arg(&b64) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()); - if self.debug { - cmd.arg("--debug"); - } - - debug!(container_id = %container_id, command = %process.command_line, "wxc-exec one-shot processContainer spawn"); let child = cmd.spawn()?; Ok(child) } @@ -565,7 +474,8 @@ mod tests { #[test] fn provision_envelope_parse_error() { - let json = r#"{"error":{"code":"backend_unavailable","message":"IsoSessionApp.dll missing"}}"#; + let json = + r#"{"error":{"code":"backend_unavailable","message":"IsoSessionApp.dll missing"}}"#; let env: ProvisionEnvelope = serde_json::from_str(json).unwrap(); assert!(env.result.is_none()); let err = env.error.unwrap(); @@ -613,35 +523,6 @@ mod tests { assert_eq!(config["filesystem"]["readwritePaths"][0], "C:\\work\\demo"); } - #[test] - fn oneshot_processcontainer_config_json_shape() { - // Mirror the JSON `run_oneshot` builds for the one-shot processContainer - // path: no `phase` (routes to one-shot), `containment: processcontainer`, - // a `process` block, the `processContainer` knobs, and filesystem grants - // incl. deniedPaths. - let config = serde_json::json!({ - "version": MXC_SCHEMA_VERSION, - "containerId": "sb-1", - "containment": "processcontainer", - "process": { - "commandLine": "C:\\work\\demo\\agent.exe", - "cwd": "C:\\work\\demo", - "env": Vec::::new(), - "timeout": 0, - }, - "processContainer": { "leastPrivilege": true }, - "filesystem": { - "readwritePaths": ["C:\\work\\demo"], - "deniedPaths": ["C:\\secret"], - }, - }); - assert_eq!(config["containment"], "processcontainer"); - assert!(config.get("phase").is_none(), "one-shot config must omit phase"); - assert_eq!(config["processContainer"]["leastPrivilege"], true); - assert_eq!(config["filesystem"]["readwritePaths"][0], "C:\\work\\demo"); - assert_eq!(config["filesystem"]["deniedPaths"][0], "C:\\secret"); - } - #[test] fn invoker_error_maps_backend_unavailable_to_unavailable() { let err = InvokerError::Mxc { diff --git a/crates/openshell-driver-mxc/src/policy.rs b/crates/openshell-driver-mxc/src/policy.rs index da1ea94f4e..bcf48ca6be 100644 --- a/crates/openshell-driver-mxc/src/policy.rs +++ b/crates/openshell-driver-mxc/src/policy.rs @@ -3,15 +3,16 @@ //! PolicyMapper seam: `SandboxPolicy` → MXC `ContainerConfig` fragment. //! -//! This skill does **not** write the actual policy mapping rules — that is -//! Giedrius's logic, **embedded** as the [`crate::policy_map`] module (team -//! decision: a module in this crate, not a separate crate). This file defines -//! the trait seam plus: +//! This file does **not** write the actual policy mapping rules — that logic is +//! **embedded** as the [`crate::policy_map`] module (the source of truth; it was +//! the standalone `openshell-policy-mapper` crate). This file defines the trait +//! seam plus: //! -//! - [`EmbeddedPolicyMapper`] — the **primary** impl. Bridges the -//! `SandboxPolicy` proto into the `serde_yaml::Value` shape the embedded -//! mapper expects, calls [`crate::policy_map::build_mxc_config`], extracts the -//! MXC filesystem shares, and rejects the create on any `error`-severity loss. +//! - [`EmbeddedPolicyMapper`] — the **primary** impl. Calls +//! [`crate::policy_map::map_to_mxc`] directly on the typed `SandboxPolicy` +//! proto (no YAML bridge), extracts the MXC filesystem shares, normalizes +//! their paths to Windows form, and rejects the create on any `error`-severity +//! loss. //! - [`StubPolicyMapper`] — a compile-only fallback that grants only the demo //! `share_dir`. Kept so the crate builds/tests without exercising the embed. //! @@ -19,7 +20,6 @@ //! `MapError::Unsupported` and are rejected in `ValidateSandboxCreate`. use openshell_core::proto::SandboxPolicy; -use serde_yaml::{Mapping, Value as YamlValue}; use thiserror::Error; /// The MXC config fragment derived from a `SandboxPolicy`. @@ -80,122 +80,26 @@ pub trait PolicyMapper: Send + Sync { // ── Path normalization ────────────────────────────────────────────────────── /// Normalize forward-slash paths to Windows backslash form. Path normalization -/// lives here (the bridge), in one place — Giedrius's mapper passes path strings -/// through unchanged. +/// lives here, in one place — the embedded mapper copies path strings through +/// unchanged. fn normalize_path(p: &str) -> String { p.replace('/', "\\") } // ── Embedded mapper (primary impl) ────────────────────────────────────────── -/// Primary `PolicyMapper`: bridges the proto policy into the embedded -/// `policy_map` module (vendored from Giedrius's mapper). +/// Primary `PolicyMapper`: calls the embedded `policy_map` module (the source of +/// truth) directly on the typed `SandboxPolicy` proto. pub struct EmbeddedPolicyMapper; -/// Convert the `SandboxPolicy` proto IR into the `serde_yaml::Value` the embedded -/// mapper consumes. **Key bridge fact:** the proto's `filesystem` field maps to -/// the YAML key **`filesystem_policy`** (the name the mapper reads). -fn policy_to_yaml(policy: &SandboxPolicy) -> YamlValue { - let mut root = Mapping::new(); - - if let Some(fs) = &policy.filesystem { - let mut fs_map = Mapping::new(); - let rw: Vec = fs - .read_write - .iter() - .map(|p| YamlValue::String(normalize_path(p))) - .collect(); - let ro: Vec = fs - .read_only - .iter() - .map(|p| YamlValue::String(normalize_path(p))) - .collect(); - fs_map.insert(YamlValue::from("read_write"), YamlValue::Sequence(rw)); - fs_map.insert(YamlValue::from("read_only"), YamlValue::Sequence(ro)); - fs_map.insert( - YamlValue::from("include_workdir"), - YamlValue::Bool(fs.include_workdir), - ); - root.insert(YamlValue::from("filesystem_policy"), YamlValue::Mapping(fs_map)); - } - - if let Some(landlock) = &policy.landlock { - if !landlock.compatibility.is_empty() { - let mut ll = Mapping::new(); - ll.insert( - YamlValue::from("compatibility"), - YamlValue::from(landlock.compatibility.clone()), - ); - root.insert(YamlValue::from("landlock"), YamlValue::Mapping(ll)); - } - } - - if let Some(process) = &policy.process { - if !process.run_as_user.is_empty() || !process.run_as_group.is_empty() { - let mut p = Mapping::new(); - if !process.run_as_user.is_empty() { - p.insert( - YamlValue::from("run_as_user"), - YamlValue::from(process.run_as_user.clone()), - ); - } - if !process.run_as_group.is_empty() { - p.insert( - YamlValue::from("run_as_group"), - YamlValue::from(process.run_as_group.clone()), - ); - } - root.insert(YamlValue::from("process"), YamlValue::Mapping(p)); - } - } - - if !policy.network_policies.is_empty() { - let mut nets = Mapping::new(); - for (name, rule) in &policy.network_policies { - let mut rule_map = Mapping::new(); - let endpoints: Vec = rule - .endpoints - .iter() - .map(|ep| { - let mut e = Mapping::new(); - if !ep.host.is_empty() { - e.insert(YamlValue::from("host"), YamlValue::from(ep.host.clone())); - } - if ep.port != 0 { - e.insert(YamlValue::from("port"), YamlValue::from(ep.port)); - } - if !ep.protocol.is_empty() { - e.insert( - YamlValue::from("protocol"), - YamlValue::from(ep.protocol.clone()), - ); - } - YamlValue::Mapping(e) - }) - .collect(); - rule_map.insert(YamlValue::from("endpoints"), YamlValue::Sequence(endpoints)); - let binaries: Vec = rule - .binaries - .iter() - .map(|b| { - let mut bm = Mapping::new(); - bm.insert(YamlValue::from("path"), YamlValue::from(b.path.clone())); - YamlValue::Mapping(bm) - }) - .collect(); - rule_map.insert(YamlValue::from("binaries"), YamlValue::Sequence(binaries)); - nets.insert(YamlValue::from(name.clone()), YamlValue::Mapping(rule_map)); - } - root.insert(YamlValue::from("network_policies"), YamlValue::Mapping(nets)); - } - - YamlValue::Mapping(root) -} - fn extract_paths(config: &serde_json::Value, key: &str) -> Vec { config["filesystem"][key] .as_array() - .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) .unwrap_or_default() } @@ -208,14 +112,21 @@ impl PolicyMapper for EmbeddedPolicyMapper { ) })?; - let yaml = policy_to_yaml(policy); - let opts = crate::policy_map::MappingOptions::for_isolation_session(ctx.sandbox_id.clone()); - let mut losses = Vec::new(); - let config = crate::policy_map::build_mxc_config(&yaml, &opts, &mut losses); + // Map directly off the typed proto. The MXC driver runs an isolation + // session, so use that containment: its network branch yields an + // `error` loss for any host allowlist, which is what rejects network + // policy below. + let opts = crate::policy_map::MxcMappingOptions { + containment: "isolation_session".to_owned(), + container_id: ctx.sandbox_id.clone(), + ..Default::default() + }; + let result = crate::policy_map::map_to_mxc(policy, &opts); // Reject the create on any error-severity loss. Warnings/info (e.g. the // filesystem default-deny note) are advisory and do not block. - let errors: Vec = losses + let errors: Vec = result + .loss .iter() .filter(|i| i.severity == "error") .map(|i| LossItem { @@ -227,8 +138,16 @@ impl PolicyMapper for EmbeddedPolicyMapper { return Err(MapError::Unsupported(errors)); } - let mut readwrite = extract_paths(&config, "readwritePaths"); - let readonly = extract_paths(&config, "readonlyPaths"); + // The embedded mapper copies paths verbatim; normalize them (and the + // demo share dir) to Windows backslash form here, in one place. + let mut readwrite: Vec = extract_paths(&result.config, "readwritePaths") + .iter() + .map(|p| normalize_path(p)) + .collect(); + let readonly: Vec = extract_paths(&result.config, "readonlyPaths") + .iter() + .map(|p| normalize_path(p)) + .collect(); // Always grant the demo host-visible share read-write so the positive // proof artifact (`hello.txt`) appears on the host. For the demo this @@ -309,9 +228,11 @@ mod tests { let ctx = demo_ctx(Some("C:/work/openshell-mxc-demo")); let config = mapper.map(Some(&policy), &ctx).unwrap(); // Forward slashes normalized to Windows backslashes by the bridge. - assert!(config - .readwrite_paths - .contains(&"C:\\work\\openshell-mxc-demo".to_string())); + assert!( + config + .readwrite_paths + .contains(&"C:\\work\\openshell-mxc-demo".to_string()) + ); assert_eq!(config.readonly_paths, vec!["C:\\tools"]); } diff --git a/crates/openshell-driver-mxc/src/policy_map.rs b/crates/openshell-driver-mxc/src/policy_map.rs deleted file mode 100644 index 12634cb81e..0000000000 --- a/crates/openshell-driver-mxc/src/policy_map.rs +++ /dev/null @@ -1,958 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -// vendored from gburachas/msft-mxc@a66cc35 (branch `policy_mapper`, -// policy_mapper/rust_policy_mapper/src/main.rs). -// -//! Embedded OpenShell-policy → MXC `ContainerConfig` mapping logic. -//! -//! This module is **vendored** from Giedrius's `rust_policy_mapper` CLI tool -//! (team decision: embed as a module, NOT a separate crate). Only the **pure** -//! mapping functions are lifted; the CLI shell (`clap`/`Args`/`main`), file -//! discovery, and output writing (`convert_policy`/`load_yaml`/`write_outputs`/ -//! `render_readme`/`build_loss_report`) are dropped, along with the `clap`, -//! `anyhow`, and `regex` dependencies they pulled in. -//! -//! Giedrius's `validate_schema` + the `jsonschema` dependency are intentionally -//! **omitted** here (per the embed plan: "not needed at runtime"). Re-add behind -//! a `schema-validation` feature if parity-validation is ever wanted in-crate. -//! -//! This module is pure `serde` and is **NOT** `#[cfg(target_os = "windows")]` -//! gated, so its parity tests run on Linux CI even though the rest of the driver -//! is Windows-only. -//! -//! **Sync plan:** Giedrius's repo stays the source of truth. Re-vendor when he -//! updates `rust_policy_mapper`; bump the `@a66cc35` marker above. The Python -//! reference for behavior is -//! `msft-mxc-gburachas/policy_mapper/python_policy_mapper/openshell_policy_to_mxc.py`. - -// Vendored module: the full mapping surface (network/L7 loss reporting, loss -// summaries) is carried for parity with Giedrius's source and Stage-2 egress, -// but the June 15 filesystem-only demo does not exercise all of it. The module -// is also compiled-but-unused on non-Windows targets (only its tests use it -// there). Both are by design, so suppress dead-code noise crate-wide here. -#![allow(dead_code)] - -use serde::Serialize; -use serde_json::{Value as JsonValue, json}; -use serde_yaml::{Mapping, Value as YamlValue}; -use std::collections::HashSet; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -const DEFAULT_COMMAND: &str = - "sh -lc \"echo OpenShell policy mapped to MXC; replace process.commandLine before running a real workload\""; -const DEFAULT_MXC_VERSION: &str = "0.7.0-alpha"; - -const OPEN_SHELL_SUPERSET_GAPS: &[&str] = &[ - "MXC UI policy has no OpenShell policy equivalent: ui.disable, ui.clipboard, and ui.injection.", - "MXC lifecycle fields have no OpenShell policy equivalent: destroyOnExit, preservePolicy, phase, and sandboxId.", - "MXC backend selection and backend-specific blocks are outside OpenShell policy YAML.", - "MXC process command, cwd, env, and timeout are runtime config fields, not OpenShell policy fields.", - "MXC explicit deniedPaths are not expressible in current OpenShell policy YAML, which relies on default-deny filesystem behavior instead.", - "MXC fallback.allowDaclMutation (host DACL mutation consent) has no OpenShell policy equivalent.", - "MXC network.allowLocalNetwork (inbound bind/listen permission) has no OpenShell policy equivalent.", - "MXC network.proxy configuration has no OpenShell policy equivalent.", - "MXC experimental backend blocks (windows_sandbox, wslc, seatbelt, isolation_session) are outside OpenShell policy YAML.", -]; - -// --------------------------------------------------------------------------- -// Mapping options (clone-friendly runtime config) -// --------------------------------------------------------------------------- - -/// Runtime knobs for the mapper. The driver constructs these with -/// [`MappingOptions::for_isolation_session`]; the upstream CLI `Args`/`build_options` -/// path is dropped. -#[derive(Clone, Debug)] -pub struct MappingOptions { - pub mxc_version: String, - pub containment: String, - pub command: String, - pub container_id: String, - pub cwd: Option, - pub env: Vec, - pub timeout_ms: u64, - pub strict: bool, - pub allow_wildcards: bool, -} - -impl MappingOptions { - /// Demo/driver defaults: target the MXC `isolation_session` backend. - pub fn for_isolation_session(container_id: impl Into) -> Self { - Self { - mxc_version: DEFAULT_MXC_VERSION.to_owned(), - containment: "isolation_session".to_owned(), - command: DEFAULT_COMMAND.to_owned(), - container_id: container_id.into(), - cwd: None, - env: Vec::new(), - timeout_ms: 0, - strict: false, - allow_wildcards: false, - } - } -} - -// --------------------------------------------------------------------------- -// Loss item -// --------------------------------------------------------------------------- - -/// A single OpenShell→MXC mapping loss/diagnostic. `severity` is one of -/// `"info"`, `"warning"`, `"error"`. The driver rejects a `CreateSandbox` -/// when any `"error"` item is present. -#[derive(Clone, Debug, Serialize)] -pub struct LossItem { - pub path: String, - pub severity: String, - pub message: String, - pub openshell_feature: String, - pub mxc_impact: String, -} - -fn add_loss( - items: &mut Vec, - path: &str, - severity: &str, - message: &str, - openshell_feature: &str, - mxc_impact: &str, -) { - items.push(LossItem { - path: path.to_owned(), - severity: severity.to_owned(), - message: message.to_owned(), - openshell_feature: openshell_feature.to_owned(), - mxc_impact: mxc_impact.to_owned(), - }); -} - -// --------------------------------------------------------------------------- -// MXC config builder (pure entry point lifted from Giedrius's mapper) -// --------------------------------------------------------------------------- - -/// Translate an OpenShell policy (as a `serde_yaml::Value`) into an MXC -/// `ContainerConfig` JSON value, appending any mapping losses to `items`. -/// -/// Top-level YAML keys consumed: `filesystem_policy`, `network_policies`, -/// `landlock`, `process`. -pub fn build_mxc_config( - policy: &YamlValue, - options: &MappingOptions, - items: &mut Vec, -) -> JsonValue { - let mut process = json!({ - "commandLine": options.command, - "timeout": options.timeout_ms, - }); - if let Some(cwd) = &options.cwd { - process["cwd"] = json!(cwd); - } - if !options.env.is_empty() { - process["env"] = json!(options.env); - } - - let filesystem = map_filesystem(policy, options, items); - let allowed_hosts = map_network(policy, options, items); - - let mut network = json!({ - "defaultPolicy": "block", - "allowedHosts": allowed_hosts, - "blockedHosts": [], - }); - if let Some(mode) = default_enforcement_mode(&options.containment, &allowed_hosts) { - network["enforcementMode"] = json!(mode); - } - - let mut config = json!({ - "version": options.mxc_version, - "containerId": options.container_id, - "containment": options.containment, - "lifecycle": { - "destroyOnExit": true, - "preservePolicy": false, - }, - "process": process, - "filesystem": filesystem, - "network": network, - "ui": { - "disable": true, - "clipboard": "none", - "injection": false, - }, - }); - - add_backend_specific_config(&mut config, &options.containment, &allowed_hosts, items); - add_static_policy_loss(policy, options, items); - config -} - -// --------------------------------------------------------------------------- -// Filesystem mapping -// --------------------------------------------------------------------------- - -fn map_filesystem(policy: &YamlValue, options: &MappingOptions, items: &mut Vec) -> JsonValue { - let raw_fs = policy.get("filesystem_policy"); - - // Python: fs_policy = policy.get("filesystem_policy") or {} - // Falsy values (None/null/empty dict) collapse to empty dict. - enum FsResult<'a> { - Map(&'a Mapping), - EmptyOrAbsent, - TypeError, - } - - let fs_result = match raw_fs { - None | Some(YamlValue::Null) => FsResult::EmptyOrAbsent, - Some(YamlValue::Mapping(m)) if m.is_empty() => FsResult::EmptyOrAbsent, - Some(YamlValue::Mapping(m)) => FsResult::Map(m), - Some(_) => FsResult::TypeError, - }; - - if matches!(fs_result, FsResult::TypeError) { - add_loss( - items, - "filesystem_policy", - "error", - "Expected filesystem_policy to be an object.", - "filesystem policy", - "No filesystem grants could be mapped.", - ); - } - - let mut readwrite: Vec = Vec::new(); - let mut readonly: Vec = Vec::new(); - - if let FsResult::Map(fs) = &fs_result { - readwrite = stable_list(fs.get("read_write")); - readonly = stable_list(fs.get("read_only")); - - let include_workdir = fs - .get("include_workdir") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - if include_workdir { - if let Some(cwd) = &options.cwd { - append_unique(&mut readwrite, cwd.clone()); - } else { - add_loss( - items, - "filesystem_policy.include_workdir", - "info", - "OpenShell includes the runtime workdir, but no cwd was supplied.", - "include_workdir", - "The generated MXC config cannot add the workdir path grant.", - ); - } - } - } - - // Python: `if not fs_policy:` fires when dict is empty/absent (all non-Map cases). - if !matches!(fs_result, FsResult::Map(_)) { - add_loss( - items, - "filesystem_policy", - "warning", - "No OpenShell filesystem_policy was present.", - "default filesystem policy", - "MXC receives empty filesystem lists; backend defaults determine visibility.", - ); - } - - add_loss( - items, - "filesystem_policy", - "warning", - &filesystem_default_deny_message(&options.containment), - "OpenShell Landlock/default-deny filesystem model", - "MXC filesystem default-deny parity is backend-specific.", - ); - - json!({ - "readwritePaths": readwrite, - "readonlyPaths": readonly, - "deniedPaths": [], - }) -} - -fn filesystem_default_deny_message(containment: &str) -> String { - match containment { - "bubblewrap" => "Bubblewrap policy is not strict OpenShell filesystem parity: MXC \ - may bind host root read-only and overlay policy mounts." - .to_owned(), - "lxc" => "LXC exposes the container rootfs and bind-mounts selected host \ - paths; this is not identical to OpenShell Landlock." - .to_owned(), - "wslc" => "WSLC mounts selected Windows paths, but default-deny behavior is \ - runner/backend specific." - .to_owned(), - "seatbelt" => "Seatbelt starts from a deny-default profile with baseline system \ - allowances, not OpenShell Landlock." - .to_owned(), - _ => "MXC filesystem behavior is backend-specific and not equivalent to \ - OpenShell Landlock by construction." - .to_owned(), - } -} - -// --------------------------------------------------------------------------- -// Network mapping -// --------------------------------------------------------------------------- - -fn map_network(policy: &YamlValue, options: &MappingOptions, items: &mut Vec) -> Vec { - let raw_net = policy.get("network_policies"); - - let net_map = match raw_net { - None | Some(YamlValue::Null) => { - add_backend_network_loss(policy, &options.containment, items); - return vec![]; - } - Some(YamlValue::Mapping(m)) if m.is_empty() => { - add_backend_network_loss(policy, &options.containment, items); - return vec![]; - } - Some(YamlValue::Mapping(m)) => m, - Some(_) => { - add_loss( - items, - "network_policies", - "error", - "Expected network_policies to be a map.", - "network policies", - "No network allowlist could be mapped.", - ); - add_backend_network_loss(policy, &options.containment, items); - return vec![]; - } - }; - - let mut allowed_hosts: Vec = Vec::new(); - - for (key_val, rule_val) in net_map.iter() { - let rule_key = yaml_as_str(key_val).unwrap_or_default(); - let rule_path = format!("network_policies.{}", rule_key); - - let rule = match rule_val.as_mapping() { - Some(m) => m, - None => { - add_loss( - items, - &rule_path, - "error", - "Expected network policy entry to be an object.", - "network policy entry", - "Entry was skipped.", - ); - continue; - } - }; - - let endpoints: Vec<&YamlValue> = match rule.get("endpoints") { - None | Some(YamlValue::Null) => vec![], - Some(YamlValue::Sequence(seq)) => seq.iter().collect(), - Some(other) => vec![other], - }; - - if endpoints.is_empty() { - add_loss( - items, - &format!("{}.endpoints", rule_path), - "error", - "OpenShell policy entry has no endpoints.", - "network endpoints", - "No MXC host allowlist entries were produced for this policy.", - ); - } - - for (index, endpoint) in endpoints.iter().enumerate() { - let endpoint_path = format!("{}.endpoints[{}]", rule_path, index); - match endpoint.as_mapping() { - None => { - add_loss( - items, - &endpoint_path, - "error", - "Expected endpoint to be an object.", - "network endpoint", - "Endpoint was skipped.", - ); - } - Some(ep) => { - map_endpoint(ep, &endpoint_path, &mut allowed_hosts, options, items); - } - } - } - - // binaries - let binaries: Vec<&YamlValue> = match rule.get("binaries") { - None | Some(YamlValue::Null) => vec![], - Some(YamlValue::Sequence(seq)) => seq.iter().collect(), - Some(other) => vec![other], - }; - - if binaries.is_empty() { - add_loss( - items, - &format!("{}.binaries", rule_path), - "error", - "OpenShell requires binary-scoped network grants; this entry has no binaries.", - "binary-scoped network policy", - "MXC cannot represent per-binary grants and scopes network to the sandbox.", - ); - } else { - for (index, binary) in binaries.iter().enumerate() { - let binary_path = match binary.as_mapping().and_then(|m| m.get("path")) { - Some(YamlValue::String(s)) => Some(s.as_str()), - _ => None, - }; - let repr = match binary_path { - Some(p) => format!("'{}'", p), - None => python_repr_yaml(binary), - }; - add_loss( - items, - &format!("{}.binaries[{}].path", rule_path, index), - "error", - &format!("Binary scope is not representable in MXC: {}.", repr), - "binary-scoped network policy", - "Dropping this would broaden access from one executable to the whole sandbox.", - ); - } - } - } - - add_backend_network_loss(policy, &options.containment, items); - allowed_hosts -} - -fn map_endpoint( - endpoint: &Mapping, - path: &str, - allowed_hosts: &mut Vec, - options: &MappingOptions, - items: &mut Vec, -) { - // host - match endpoint.get("host") { - None | Some(YamlValue::Null) => { - add_loss( - items, - &format!("{}.host", path), - "error", - "Endpoint has no host.", - "network endpoint host", - "Endpoint was not added to MXC allowedHosts.", - ); - } - Some(host_val) => { - let host_str = yaml_to_string(host_val); - if contains_wildcard(&host_str) { - let (message, impact) = if options.allow_wildcards { - append_unique(allowed_hosts, host_str.clone()); - ( - format!( - "Wildcard host emitted despite non-portable MXC semantics: {}.", - host_str - ), - "Backend behavior is not portable and may fail or broaden access.", - ) - } else { - ( - format!( - "Wildcard host omitted because MXC has no portable syntax: {}.", - host_str - ), - "Generated MXC config is more restrictive for this endpoint.", - ) - }; - add_loss( - items, - &format!("{}.host", path), - "error", - &message, - "OpenShell wildcard host matching", - impact, - ); - } else { - append_unique(allowed_hosts, host_str); - } - } - } - - // port / ports - for field in &["port", "ports"] { - if let Some(val) = endpoint.get(*field) { - if !matches!(val, YamlValue::Null) { - let repr = yaml_repr_value(val); - add_loss( - items, - &format!("{}.{}", path, field), - "error", - &format!("MXC allowedHosts cannot encode port constraint {}.", repr), - "port-scoped outbound policy", - "MXC allows or blocks the host as a whole.", - ); - } - } - } - - // allowed_ips - let ips = stable_list(endpoint.get("allowed_ips")); - let host_for_msg = endpoint.get("host").map(yaml_to_string).unwrap_or_default(); - for ip in ips { - append_unique(allowed_hosts, ip.clone()); - add_loss( - items, - &format!("{}.allowed_ips", path), - "warning", - &format!( - "MXC can carry CIDR/IP '{}', but cannot bind it to DNS for '{}'.", - ip, host_for_msg - ), - "DNS result pinning / SSRF override", - "The CIDR/IP becomes a standalone allowed destination.", - ); - } - - report_endpoint_l7_losses(endpoint, path, items); -} - -fn report_endpoint_l7_losses(endpoint: &Mapping, path: &str, items: &mut Vec) { - if let Some(protocol) = endpoint.get("protocol").and_then(|v| v.as_str()) { - add_loss( - items, - &format!("{}.protocol", path), - "error", - &format!("MXC has no protocol-aware policy equivalent for '{}'.", protocol), - "protocol-aware proxy policy", - "MXC host filtering cannot enforce REST/WebSocket/GraphQL semantics.", - ); - } - - if let Some(tls) = endpoint.get("tls").and_then(|v| v.as_str()) { - let severity = if tls == "skip" { "warning" } else { "error" }; - add_loss( - items, - &format!("{}.tls", path), - severity, - &format!("MXC has no OpenShell TLS inspection mode equivalent for '{}'.", tls), - "TLS inspection mode", - "MXC network policy is host-level only.", - ); - } - - if let Some(enforcement) = endpoint.get("enforcement").and_then(|v| v.as_str()) { - if enforcement == "audit" { - add_loss( - items, - &format!("{}.enforcement", path), - "error", - "MXC has no audit-only network policy mode.", - "audit-mode endpoint", - "Generated MXC config enforces host-level default block instead.", - ); - } else { - add_loss( - items, - &format!("{}.enforcement", path), - "warning", - "MXC enforcementMode is backend-wide, not per endpoint.", - "per-endpoint enforcement", - "The mapper chooses a backend-level enforcement mode.", - ); - } - } - - if let Some(access) = endpoint.get("access").and_then(|v| v.as_str()) { - add_loss( - items, - &format!("{}.access", path), - "error", - &format!("MXC has no access preset equivalent for '{}'.", access), - "REST/WebSocket/GraphQL access preset", - "MXC cannot enforce method or operation-level access.", - ); - } - - if endpoint.get("rules").is_some_and(|v| !matches!(v, YamlValue::Null)) { - add_loss( - items, - &format!("{}.rules", path), - "error", - "MXC has no L7 allow-rule equivalent.", - "REST/WebSocket/GraphQL allow rules", - "Method/path/query/operation restrictions are lost.", - ); - } - - if endpoint - .get("deny_rules") - .is_some_and(|v| !matches!(v, YamlValue::Null)) - { - add_loss( - items, - &format!("{}.deny_rules", path), - "error", - "MXC has no L7 deny-rule equivalent.", - "L7 deny rules", - "Deny precedence over broad allows is lost.", - ); - } - - // boolean L7 losses - let bool_losses: &[(&str, &str)] = &[ - ("allow_encoded_slash", "encoded slash handling"), - ("websocket_credential_rewrite", "WebSocket credential rewrite"), - ("request_body_credential_rewrite", "request-body credential rewrite"), - ]; - for (field, feature) in bool_losses { - if endpoint.get(*field).and_then(|v| v.as_bool()).unwrap_or(false) { - add_loss( - items, - &format!("{}.{}", path, field), - "error", - &format!("MXC has no equivalent for {}.", feature), - feature, - "Generated config cannot preserve this proxy behavior.", - ); - } - } - - // GraphQL losses - let graphql_fields: &[&str] = &[ - "persisted_queries", - "graphql_persisted_queries", - "graphql_max_body_bytes", - ]; - for field in graphql_fields { - if endpoint.get(*field).is_some_and(|v| !matches!(v, YamlValue::Null)) { - add_loss( - items, - &format!("{}.{}", path, field), - "error", - &format!("MXC has no GraphQL policy equivalent for {}.", field), - "GraphQL operation policy", - "GraphQL inspection and persisted-query behavior is lost.", - ); - } - } -} - -// --------------------------------------------------------------------------- -// Static policy losses (landlock, process identity) -// --------------------------------------------------------------------------- - -fn add_static_policy_loss(policy: &YamlValue, options: &MappingOptions, items: &mut Vec) { - if let Some(ll) = policy.get("landlock") { - if !matches!(ll, YamlValue::Null) { - add_loss( - items, - "landlock", - "warning", - "MXC has no Landlock compatibility mode field.", - "Landlock LSM enforcement", - "Backend filesystem controls may not fail like OpenShell best_effort/hard_requirement.", - ); - } - } - - if let Some(process) = policy.get("process").and_then(|v| v.as_mapping()) { - for field in &["run_as_user", "run_as_group"] { - if process.get(*field).is_some_and(|v| !matches!(v, YamlValue::Null)) { - add_loss( - items, - &format!("process.{}", field), - "warning", - &format!("MXC has no portable equivalent for OpenShell {}.", field), - "process identity", - "MXC backend identity is selected outside this policy mapping.", - ); - } - } - } - - if options.containment == "processcontainer" { - let fs = policy.get("filesystem_policy").and_then(|v| v.as_mapping()); - if let Some(fs_map) = fs { - let linux_paths: Vec = stable_list(fs_map.get("read_only")) - .into_iter() - .chain(stable_list(fs_map.get("read_write"))) - .collect(); - if linux_paths.iter().any(|p| p.starts_with('/')) { - add_loss( - items, - "filesystem_policy", - "warning", - "OpenShell example paths are Linux paths; Windows ProcessContainer expects Windows paths.", - "filesystem path syntax", - "Run with path translation or target a Linux-like MXC backend.", - ); - } - } - } -} - -// --------------------------------------------------------------------------- -// Backend-specific config additions -// --------------------------------------------------------------------------- - -fn add_backend_specific_config( - config: &mut JsonValue, - containment: &str, - allowed_hosts: &[String], - items: &mut Vec, -) { - match containment { - "processcontainer" | "process" => { - if !allowed_hosts.is_empty() { - config["processContainer"] = json!({"capabilities": ["internetClient"]}); - } - } - "lxc" => { - config["lxc"] = json!({"distribution": "alpine", "release": "3.20"}); - } - c @ ("windows_sandbox" | "isolation_session" | "vm") if !allowed_hosts.is_empty() => { - add_loss( - items, - "containment", - "error", - &format!("{} is not a v0 target for OpenShell network policy mapping.", c), - "OpenShell network policy", - "MXC network behavior is unsupported or unknown for this backend.", - ); - } - "microvm" if !allowed_hosts.is_empty() => { - add_loss( - items, - "containment", - "error", - "microvm network policy enforcement is not defined for this mapper.", - "OpenShell network policy", - "MXC network behavior is unsupported or unknown for microvm.", - ); - } - _ => {} - } -} - -fn add_backend_network_loss(policy: &YamlValue, containment: &str, items: &mut Vec) { - // Only fire when network_policies is present and non-empty - let has_net = policy - .get("network_policies") - .is_some_and(|v| matches!(v, YamlValue::Mapping(m) if !m.is_empty())); - if !has_net { - return; - } - - match containment { - "seatbelt" => { - add_loss( - items, - "network_policies", - "error", - "MXC Seatbelt cannot faithfully enforce arbitrary allowedHosts.", - "host allowlist", - "Seatbelt allowlists can broaden to allow-all outbound.", - ); - } - "processcontainer" | "process" => { - add_loss( - items, - "network_policies", - "warning", - "Windows ProcessContainer host allowlists are possible but fragile.", - "host allowlist", - "Review firewall/capability behavior before treating this as parity.", - ); - } - "wslc" => { - add_loss( - items, - "network_policies", - "warning", - "WSLC host filtering relies on bridged networking plus in-container iptables.", - "host allowlist", - "Backend privileges and runner behavior determine parity.", - ); - } - "vm" | "windows_sandbox" => { - add_loss( - items, - "network_policies", - "error", - "MXC Windows Sandbox / vm cannot faithfully enforce arbitrary allowedHosts.", - "host allowlist", - "Network policy enforcement is unsupported or unknown for this backend.", - ); - } - _ => {} - } -} - -fn default_enforcement_mode(containment: &str, allowed_hosts: &[String]) -> Option<&'static str> { - if allowed_hosts.is_empty() { - return None; - } - match containment { - "lxc" | "bubblewrap" | "hyperlight" => Some("firewall"), - "processcontainer" | "process" => Some("both"), - "wslc" | "seatbelt" | "microvm" | "vm" | "windows_sandbox" => None, - _ => Some("firewall"), - } -} - -// --------------------------------------------------------------------------- -// Loss summary helpers -// --------------------------------------------------------------------------- - -/// Distinct OpenShell features that could not be represented (error/warning). -pub fn summarize_missing_mxc(items: &[LossItem]) -> Vec { - let mut seen: HashSet = HashSet::new(); - let mut summary: Vec = Vec::new(); - for item in items { - if (item.severity == "error" || item.severity == "warning") - && !seen.contains(&item.openshell_feature) - { - seen.insert(item.openshell_feature.clone()); - summary.push(item.openshell_feature.clone()); - } - } - summary -} - -/// Static MXC features that have no OpenShell-policy equivalent. -pub fn open_shell_superset_gaps() -> &'static [&'static str] { - OPEN_SHELL_SUPERSET_GAPS -} - -// --------------------------------------------------------------------------- -// YAML utilities -// --------------------------------------------------------------------------- - -/// Convert a YAML value to a Vec following Python's `stable_list`. -/// None/null → []; sequence → flattened strings; scalar → [scalar]. -fn stable_list(v: Option<&YamlValue>) -> Vec { - match v { - None | Some(YamlValue::Null) => vec![], - Some(YamlValue::Sequence(seq)) => seq.iter().map(yaml_to_string).collect(), - Some(other) => vec![yaml_to_string(other)], - } -} - -fn yaml_to_string(v: &YamlValue) -> String { - match v { - YamlValue::String(s) => s.clone(), - YamlValue::Number(n) => n.to_string(), - YamlValue::Bool(b) => b.to_string(), - YamlValue::Null => String::new(), - _ => format!("{:?}", v), - } -} - -fn yaml_as_str(v: &YamlValue) -> Option<&str> { - v.as_str() -} - -/// Python repr-style formatting of a YAML scalar. -fn yaml_repr_value(v: &YamlValue) -> String { - match v { - YamlValue::Number(n) => n.to_string(), - YamlValue::String(s) => format!("'{}'", s), - YamlValue::Bool(b) => b.to_string(), - YamlValue::Null => "None".to_string(), - _ => format!("{:?}", v), - } -} - -/// Python repr for an arbitrary YAML value (used for binary path fallback). -fn python_repr_yaml(v: &YamlValue) -> String { - match v { - YamlValue::String(s) => format!("'{}'", s), - YamlValue::Number(n) => n.to_string(), - YamlValue::Bool(b) => b.to_string(), - YamlValue::Null => "None".to_string(), - YamlValue::Mapping(_) => "{...}".to_string(), - YamlValue::Sequence(_) => "[...]".to_string(), - _ => format!("{:?}", v), - } -} - -fn append_unique(list: &mut Vec, value: String) { - if !list.contains(&value) { - list.push(value); - } -} - -fn contains_wildcard(host: &str) -> bool { - host.contains('*') -} - -// --------------------------------------------------------------------------- -// Tests — parity against Giedrius's mapper behavior (run on Linux CI too) -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - - fn fs_policy_yaml(rw: &[&str], ro: &[&str]) -> YamlValue { - let rw_seq: Vec<&str> = rw.to_vec(); - let ro_seq: Vec<&str> = ro.to_vec(); - serde_yaml::from_str(&format!( - "filesystem_policy:\n read_write: {:?}\n read_only: {:?}\n", - rw_seq, ro_seq - )) - .unwrap() - } - - #[test] - fn filesystem_read_write_maps_to_readwrite_paths() { - let policy = fs_policy_yaml(&["C:\\work\\demo"], &[]); - let opts = MappingOptions::for_isolation_session("demo"); - let mut items = Vec::new(); - let config = build_mxc_config(&policy, &opts, &mut items); - assert_eq!(config["filesystem"]["readwritePaths"][0], "C:\\work\\demo"); - assert_eq!( - config["filesystem"]["readonlyPaths"] - .as_array() - .map(|a| a.len()), - Some(0) - ); - // Filesystem-only policy: only warnings/info, no errors → not rejected. - assert_eq!(items.iter().filter(|i| i.severity == "error").count(), 0); - } - - #[test] - fn read_only_paths_map_through() { - let policy = fs_policy_yaml(&[], &["C:\\tools"]); - let opts = MappingOptions::for_isolation_session("demo"); - let mut items = Vec::new(); - let config = build_mxc_config(&policy, &opts, &mut items); - assert_eq!(config["filesystem"]["readonlyPaths"][0], "C:\\tools"); - } - - #[test] - fn network_policy_on_isolation_session_is_an_error_loss() { - // A network policy with an endpoint host produces an allowedHosts entry, - // which on isolation_session is an error (rejected by the driver). - let policy: YamlValue = serde_yaml::from_str( - "filesystem_policy:\n read_write: []\n read_only: []\nnetwork_policies:\n api:\n endpoints:\n - host: example.com\n binaries:\n - path: /usr/bin/curl\n", - ) - .unwrap(); - let opts = MappingOptions::for_isolation_session("demo"); - let mut items = Vec::new(); - let _ = build_mxc_config(&policy, &opts, &mut items); - assert!(items.iter().any(|i| i.severity == "error")); - } - - #[test] - fn type_error_filesystem_is_an_error_loss() { - let policy: YamlValue = serde_yaml::from_str("filesystem_policy: \"not-a-map\"\n").unwrap(); - let opts = MappingOptions::for_isolation_session("demo"); - let mut items = Vec::new(); - let _ = build_mxc_config(&policy, &opts, &mut items); - assert!(items.iter().any(|i| i.severity == "error")); - } -} diff --git a/crates/openshell-driver-mxc/src/policy_map/config.rs b/crates/openshell-driver-mxc/src/policy_map/config.rs new file mode 100644 index 0000000000..f64b64a54a --- /dev/null +++ b/crates/openshell-driver-mxc/src/policy_map/config.rs @@ -0,0 +1,143 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! MXC `ContainerConfig` defaults and backend-specific behavior. + +use openshell_core::proto::SandboxPolicy; +use serde_json::{Value, json}; + +use super::loss::{LossItem, add_loss}; + +/// Placeholder command written into `process.commandLine` when the caller does +/// not supply a real workload command. +pub const DEFAULT_COMMAND: &str = "sh -lc \"echo OpenShell policy mapped to MXC; replace process.commandLine before running a real workload\""; + +/// Default MXC schema version emitted in `version`. +pub const DEFAULT_MXC_VERSION: &str = "0.7.0-alpha"; + +/// Default MXC containment backend for the coarse mapping. +pub const DEFAULT_CONTAINMENT: &str = "bubblewrap"; + +/// Select a default `network.enforcementMode` for the backend, or `None` to +/// omit the field (backends that derive enforcement from host lists / proxy). +pub fn default_enforcement_mode( + containment: &str, + allowed_hosts: &[String], +) -> Option<&'static str> { + if allowed_hosts.is_empty() { + return None; + } + match containment { + "processcontainer" | "process" => Some("both"), + "wslc" | "seatbelt" | "microvm" | "vm" | "windows_sandbox" => None, + // lxc, bubblewrap, hyperlight, and anything else default to firewall. + _ => Some("firewall"), + } +} + +/// Backend-specific advisory about how filesystem default-deny differs from +/// `OpenShell` Landlock. +pub fn filesystem_default_deny_message(containment: &str) -> String { + match containment { + "bubblewrap" => "Bubblewrap policy is not strict OpenShell filesystem parity: MXC \ + may bind host root read-only and overlay policy mounts." + .to_owned(), + "lxc" => "LXC exposes the container rootfs and bind-mounts selected host \ + paths; this is not identical to OpenShell Landlock." + .to_owned(), + "wslc" => "WSLC mounts selected Windows paths, but default-deny behavior is \ + runner/backend specific." + .to_owned(), + "seatbelt" => "Seatbelt starts from a deny-default profile with baseline system \ + allowances, not OpenShell Landlock." + .to_owned(), + _ => "MXC filesystem behavior is backend-specific and not equivalent to \ + OpenShell Landlock by construction." + .to_owned(), + } +} + +/// Add backend-specific config blocks (and reject unsupported backends). +pub fn add_backend_specific_config( + config: &mut Value, + containment: &str, + allowed_hosts: &[String], + items: &mut Vec, +) { + match containment { + "processcontainer" | "process" if !allowed_hosts.is_empty() => { + config["processContainer"] = json!({ "capabilities": ["internetClient"] }); + } + "lxc" => { + config["lxc"] = json!({ "distribution": "alpine", "release": "3.20" }); + } + backend @ ("windows_sandbox" | "isolation_session" | "vm") if !allowed_hosts.is_empty() => { + add_loss( + items, + "containment", + "error", + &format!("{backend} is not a v0 target for OpenShell network policy mapping."), + "OpenShell network policy", + "MXC network behavior is unsupported or unknown for this backend.", + ); + } + "microvm" if !allowed_hosts.is_empty() => { + add_loss( + items, + "containment", + "error", + "microvm network policy enforcement is not defined for this mapper.", + "OpenShell network policy", + "MXC network behavior is unsupported or unknown for microvm.", + ); + } + _ => {} + } +} + +/// Add a backend-specific advisory about host-allowlist fidelity. Only fires +/// when the source policy declares network rules. +pub fn add_backend_network_loss( + policy: &SandboxPolicy, + containment: &str, + items: &mut Vec, +) { + if policy.network_policies.is_empty() { + return; + } + match containment { + "seatbelt" => add_loss( + items, + "network_policies", + "error", + "MXC Seatbelt cannot faithfully enforce arbitrary allowedHosts.", + "host allowlist", + "Seatbelt allowlists can broaden to allow-all outbound.", + ), + "processcontainer" | "process" => add_loss( + items, + "network_policies", + "warning", + "Windows ProcessContainer host allowlists are possible but fragile.", + "host allowlist", + "Review firewall/capability behavior before treating this as parity.", + ), + "wslc" => add_loss( + items, + "network_policies", + "warning", + "WSLC host filtering relies on bridged networking plus in-container iptables.", + "host allowlist", + "Backend privileges and runner behavior determine parity.", + ), + "vm" | "windows_sandbox" => add_loss( + items, + "network_policies", + "error", + "MXC Windows Sandbox / vm cannot faithfully enforce arbitrary allowedHosts.", + "host allowlist", + "Network policy enforcement is unsupported or unknown for this backend.", + ), + _ => {} + } +} diff --git a/crates/openshell-driver-mxc/src/policy_map/loss.rs b/crates/openshell-driver-mxc/src/policy_map/loss.rs new file mode 100644 index 0000000000..9e83c20a78 --- /dev/null +++ b/crates/openshell-driver-mxc/src/policy_map/loss.rs @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Loss-report model shared by the coarse map and the lossless split. + +use std::collections::HashSet; + +use serde::Serialize; + +/// A single mapping observation: something that could not be represented in +/// MXC, was delegated elsewhere, or is informational. +/// +/// `severity` is one of `"error"`, `"warning"`, or `"info"`. `"error"` marks a +/// semantic broadening or an unsupported parity gap; `"warning"` marks lost +/// information that does not obviously broaden access; `"info"` is advisory. +#[derive(Clone, Debug, Serialize)] +pub struct LossItem { + pub path: String, + pub severity: String, + pub message: String, + pub openshell_feature: String, + pub mxc_impact: String, +} + +/// MXC capabilities that have no `OpenShell` *policy* equivalent. Surfaced in the +/// loss report so reviewers understand the mapping is not symmetric. +pub const OPEN_SHELL_SUPERSET_GAPS: &[&str] = &[ + "MXC UI policy has no OpenShell policy equivalent: ui.disable, ui.clipboard, and ui.injection.", + "MXC lifecycle fields have no OpenShell policy equivalent: destroyOnExit, preservePolicy, phase, and sandboxId.", + "MXC backend selection and backend-specific blocks are outside OpenShell policy YAML.", + "MXC process command, cwd, env, and timeout are runtime config fields, not OpenShell policy fields.", + "MXC explicit deniedPaths are not expressible in current OpenShell policy YAML, which relies on default-deny filesystem behavior instead.", + "MXC fallback.allowDaclMutation (host DACL mutation consent) has no OpenShell policy equivalent.", + "MXC network.allowLocalNetwork (inbound bind/listen permission) has no OpenShell policy equivalent.", + "MXC network.proxy configuration has no OpenShell policy equivalent.", + "MXC experimental backend blocks (windows_sandbox, wslc, seatbelt, isolation_session) are outside OpenShell policy YAML.", +]; + +pub fn add_loss( + items: &mut Vec, + path: &str, + severity: &str, + message: &str, + openshell_feature: &str, + mxc_impact: &str, +) { + items.push(LossItem { + path: path.to_owned(), + severity: severity.to_owned(), + message: message.to_owned(), + openshell_feature: openshell_feature.to_owned(), + mxc_impact: mxc_impact.to_owned(), + }); +} + +/// Distinct `OpenShell` features that were lost or degraded, in first-seen order. +pub fn summarize_missing_mxc(items: &[LossItem]) -> Vec { + let mut seen: HashSet<&str> = HashSet::new(); + let mut summary = Vec::new(); + for item in items { + if (item.severity == "error" || item.severity == "warning") + && seen.insert(item.openshell_feature.as_str()) + { + summary.push(item.openshell_feature.clone()); + } + } + summary +} diff --git a/crates/openshell-driver-mxc/src/policy_map/map.rs b/crates/openshell-driver-mxc/src/policy_map/map.rs new file mode 100644 index 0000000000..6ec25704b4 --- /dev/null +++ b/crates/openshell-driver-mxc/src/policy_map/map.rs @@ -0,0 +1,539 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Coarse OpenShell-policy → MXC `ContainerConfig` mapping. +//! +//! Operates on the typed [`SandboxPolicy`] (parse it with +//! `openshell_policy::parse_sandbox_policy`). Network policy is flattened into +//! an MXC host allowlist; everything MXC cannot express is recorded as a loss +//! item. The top-level `network_policies` map is iterated in sorted key order +//! so the output is deterministic (the proto map is unordered). + +use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule, SandboxPolicy}; +use serde_json::{Value, json}; + +use super::config::{ + DEFAULT_COMMAND, DEFAULT_CONTAINMENT, DEFAULT_MXC_VERSION, add_backend_network_loss, + add_backend_specific_config, default_enforcement_mode, filesystem_default_deny_message, +}; +use super::loss::{LossItem, add_loss}; + +/// Options controlling the generated MXC config. Fields not relevant to the +/// coarse map (e.g. `proxy_localhost_port`) are reserved for the lossless +/// split. +#[derive(Clone, Debug)] +pub struct MxcMappingOptions { + /// MXC schema version written into `version`. + pub mxc_version: String, + /// MXC containment backend. + pub containment: String, + /// `process.commandLine` value. + pub command: String, + /// Resolved `containerId`. + pub container_id: String, + /// Working directory; resolves `filesystem_policy.include_workdir`. + pub cwd: Option, + /// `KEY=VALUE` entries added to `process.env`. + pub env: Vec, + /// `process.timeout`. + pub timeout_ms: u64, + /// Emit `OpenShell` wildcard hosts into `allowedHosts` despite lossiness. + pub allow_wildcards: bool, + /// Governed-egress redirect port (used by the lossless split, not the + /// coarse map). + pub proxy_localhost_port: Option, +} + +impl Default for MxcMappingOptions { + fn default() -> Self { + Self { + mxc_version: DEFAULT_MXC_VERSION.to_owned(), + containment: DEFAULT_CONTAINMENT.to_owned(), + command: DEFAULT_COMMAND.to_owned(), + container_id: "openshell-policy".to_owned(), + cwd: None, + env: Vec::new(), + timeout_ms: 0, + allow_wildcards: false, + proxy_localhost_port: None, + } + } +} + +/// Result of a coarse mapping: the MXC config plus the loss items. +#[derive(Clone, Debug)] +pub struct MxcMappingResult { + pub config: Value, + pub loss: Vec, +} + +/// Map an `OpenShell` policy to a coarse MXC `ContainerConfig`. +pub fn map_to_mxc(policy: &SandboxPolicy, opts: &MxcMappingOptions) -> MxcMappingResult { + let mut loss = Vec::new(); + let config = build_mxc_config(policy, opts, &mut loss); + MxcMappingResult { config, loss } +} + +fn build_mxc_config( + policy: &SandboxPolicy, + opts: &MxcMappingOptions, + items: &mut Vec, +) -> Value { + let mut process = json!({ + "commandLine": opts.command, + "timeout": opts.timeout_ms, + }); + if let Some(cwd) = &opts.cwd { + process["cwd"] = json!(cwd); + } + if !opts.env.is_empty() { + process["env"] = json!(opts.env); + } + + let filesystem = map_filesystem(policy, opts, items); + let allowed_hosts = map_network(policy, opts, items); + + let mut network = json!({ + "defaultPolicy": "block", + "allowedHosts": allowed_hosts, + "blockedHosts": [], + }); + if let Some(mode) = default_enforcement_mode(&opts.containment, &allowed_hosts) { + network["enforcementMode"] = json!(mode); + } + + let mut config = json!({ + "version": opts.mxc_version, + "containerId": opts.container_id, + "containment": opts.containment, + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false, + }, + "process": process, + "filesystem": filesystem, + "network": network, + "ui": { + "disable": true, + "clipboard": "none", + "injection": false, + }, + }); + + add_backend_specific_config(&mut config, &opts.containment, &allowed_hosts, items); + add_static_policy_loss(policy, opts, items); + config +} + +fn map_filesystem( + policy: &SandboxPolicy, + opts: &MxcMappingOptions, + items: &mut Vec, +) -> Value { + let mut readwrite: Vec = Vec::new(); + let mut readonly: Vec = Vec::new(); + + match &policy.filesystem { + Some(fs) => { + readwrite.clone_from(&fs.read_write); + readonly.clone_from(&fs.read_only); + if fs.include_workdir { + if let Some(cwd) = &opts.cwd { + append_unique(&mut readwrite, cwd.clone()); + } else { + add_loss( + items, + "filesystem_policy.include_workdir", + "info", + "OpenShell includes the runtime workdir, but no --cwd was supplied.", + "include_workdir", + "The generated MXC config cannot add the workdir path grant.", + ); + } + } + } + None => add_loss( + items, + "filesystem_policy", + "warning", + "No OpenShell filesystem_policy was present.", + "default filesystem policy", + "MXC receives empty filesystem lists; backend defaults determine visibility.", + ), + } + + add_loss( + items, + "filesystem_policy", + "warning", + &filesystem_default_deny_message(&opts.containment), + "OpenShell Landlock/default-deny filesystem model", + "MXC filesystem default-deny parity is backend-specific.", + ); + + json!({ + "readwritePaths": readwrite, + "readonlyPaths": readonly, + "deniedPaths": [], + }) +} + +fn map_network( + policy: &SandboxPolicy, + opts: &MxcMappingOptions, + items: &mut Vec, +) -> Vec { + if policy.network_policies.is_empty() { + add_backend_network_loss(policy, &opts.containment, items); + return Vec::new(); + } + + // The proto map is unordered; sort by rule key for deterministic output. + let mut rules: Vec<(&String, &NetworkPolicyRule)> = policy.network_policies.iter().collect(); + rules.sort_by(|a, b| a.0.cmp(b.0)); + + let mut allowed_hosts: Vec = Vec::new(); + + for (key, rule) in rules { + let rule_path = format!("network_policies.{key}"); + + if rule.endpoints.is_empty() { + add_loss( + items, + &format!("{rule_path}.endpoints"), + "error", + "OpenShell policy entry has no endpoints.", + "network endpoints", + "No MXC host allowlist entries were produced for this policy.", + ); + } + for (index, endpoint) in rule.endpoints.iter().enumerate() { + let endpoint_path = format!("{rule_path}.endpoints[{index}]"); + map_endpoint(endpoint, &endpoint_path, &mut allowed_hosts, opts, items); + } + + if rule.binaries.is_empty() { + add_loss( + items, + &format!("{rule_path}.binaries"), + "error", + "OpenShell requires binary-scoped network grants; this entry has no binaries.", + "binary-scoped network policy", + "MXC cannot represent per-binary grants and scopes network to the sandbox.", + ); + } else { + for (index, binary) in rule.binaries.iter().enumerate() { + add_loss( + items, + &format!("{rule_path}.binaries[{index}].path"), + "error", + &format!( + "Binary scope is not representable in MXC: '{}'.", + binary.path + ), + "binary-scoped network policy", + "Dropping this would broaden access from one executable to the whole sandbox.", + ); + } + } + } + + add_backend_network_loss(policy, &opts.containment, items); + allowed_hosts +} + +fn map_endpoint( + endpoint: &NetworkEndpoint, + path: &str, + allowed_hosts: &mut Vec, + opts: &MxcMappingOptions, + items: &mut Vec, +) { + // host + if endpoint.host.is_empty() { + add_loss( + items, + &format!("{path}.host"), + "error", + "Endpoint has no host.", + "network endpoint host", + "Endpoint was not added to MXC allowedHosts.", + ); + } else if contains_wildcard(&endpoint.host) { + let (message, impact) = if opts.allow_wildcards { + append_unique(allowed_hosts, endpoint.host.clone()); + ( + format!( + "Wildcard host emitted despite non-portable MXC semantics: {}.", + endpoint.host + ), + "Backend behavior is not portable and may fail or broaden access.", + ) + } else { + ( + format!( + "Wildcard host omitted because MXC has no portable syntax: {}.", + endpoint.host + ), + "Generated MXC config is more restrictive for this endpoint.", + ) + }; + add_loss( + items, + &format!("{path}.host"), + "error", + &message, + "OpenShell wildcard host matching", + impact, + ); + } else { + append_unique(allowed_hosts, endpoint.host.clone()); + } + + // port / ports (the proto normalizes a single port into `ports`) + if !endpoint.ports.is_empty() { + let (field, repr) = if endpoint.ports.len() == 1 { + ("port", endpoint.ports[0].to_string()) + } else { + ("ports", format!("{:?}", endpoint.ports)) + }; + add_loss( + items, + &format!("{path}.{field}"), + "error", + &format!("MXC allowedHosts cannot encode port constraint {repr}."), + "port-scoped outbound policy", + "MXC allows or blocks the host as a whole.", + ); + } + + // allowed_ips + for ip in &endpoint.allowed_ips { + append_unique(allowed_hosts, ip.clone()); + add_loss( + items, + &format!("{path}.allowed_ips"), + "warning", + &format!( + "MXC can carry CIDR/IP '{ip}', but cannot bind it to DNS for '{}'.", + endpoint.host + ), + "DNS result pinning / SSRF override", + "The CIDR/IP becomes a standalone allowed destination.", + ); + } + + report_endpoint_l7_losses(endpoint, path, items); +} + +fn report_endpoint_l7_losses(endpoint: &NetworkEndpoint, path: &str, items: &mut Vec) { + if !endpoint.protocol.is_empty() { + add_loss( + items, + &format!("{path}.protocol"), + "error", + &format!( + "MXC has no protocol-aware policy equivalent for '{}'.", + endpoint.protocol + ), + "protocol-aware proxy policy", + "MXC host filtering cannot enforce REST/WebSocket/GraphQL semantics.", + ); + } + + if !endpoint.tls.is_empty() { + let severity = if endpoint.tls == "skip" { + "warning" + } else { + "error" + }; + add_loss( + items, + &format!("{path}.tls"), + severity, + &format!( + "MXC has no OpenShell TLS inspection mode equivalent for '{}'.", + endpoint.tls + ), + "TLS inspection mode", + "MXC network policy is host-level only.", + ); + } + + if !endpoint.enforcement.is_empty() { + if endpoint.enforcement == "audit" { + add_loss( + items, + &format!("{path}.enforcement"), + "error", + "MXC has no audit-only network policy mode.", + "audit-mode endpoint", + "Generated MXC config enforces host-level default block instead.", + ); + } else { + add_loss( + items, + &format!("{path}.enforcement"), + "warning", + "MXC enforcementMode is backend-wide, not per endpoint.", + "per-endpoint enforcement", + "The mapper chooses a backend-level enforcement mode.", + ); + } + } + + if !endpoint.access.is_empty() { + add_loss( + items, + &format!("{path}.access"), + "error", + &format!( + "MXC has no access preset equivalent for '{}'.", + endpoint.access + ), + "REST/WebSocket/GraphQL access preset", + "MXC cannot enforce method or operation-level access.", + ); + } + + if !endpoint.rules.is_empty() { + add_loss( + items, + &format!("{path}.rules"), + "error", + "MXC has no L7 allow-rule equivalent.", + "REST/WebSocket/GraphQL allow rules", + "Method/path/query/operation restrictions are lost.", + ); + } + + if !endpoint.deny_rules.is_empty() { + add_loss( + items, + &format!("{path}.deny_rules"), + "error", + "MXC has no L7 deny-rule equivalent.", + "L7 deny rules", + "Deny precedence over broad allows is lost.", + ); + } + + let bool_losses: &[(bool, &str, &str)] = &[ + ( + endpoint.allow_encoded_slash, + "allow_encoded_slash", + "encoded slash handling", + ), + ( + endpoint.websocket_credential_rewrite, + "websocket_credential_rewrite", + "WebSocket credential rewrite", + ), + ( + endpoint.request_body_credential_rewrite, + "request_body_credential_rewrite", + "request-body credential rewrite", + ), + ]; + for (set, field, feature) in bool_losses { + if *set { + add_loss( + items, + &format!("{path}.{field}"), + "error", + &format!("MXC has no equivalent for {feature}."), + feature, + "Generated config cannot preserve this proxy behavior.", + ); + } + } + + // GraphQL + if !endpoint.persisted_queries.is_empty() { + add_graphql_loss(items, path, "persisted_queries"); + } + if !endpoint.graphql_persisted_queries.is_empty() { + add_graphql_loss(items, path, "graphql_persisted_queries"); + } + if endpoint.graphql_max_body_bytes > 0 { + add_graphql_loss(items, path, "graphql_max_body_bytes"); + } +} + +fn add_graphql_loss(items: &mut Vec, path: &str, field: &str) { + add_loss( + items, + &format!("{path}.{field}"), + "error", + &format!("MXC has no GraphQL policy equivalent for {field}."), + "GraphQL operation policy", + "GraphQL inspection and persisted-query behavior is lost.", + ); +} + +fn add_static_policy_loss( + policy: &SandboxPolicy, + opts: &MxcMappingOptions, + items: &mut Vec, +) { + if policy.landlock.is_some() { + add_loss( + items, + "landlock", + "warning", + "MXC has no Landlock compatibility mode field.", + "Landlock LSM enforcement", + "Backend filesystem controls may not fail like OpenShell best_effort/hard_requirement.", + ); + } + + if let Some(process) = &policy.process { + if !process.run_as_user.is_empty() { + add_process_identity_loss(items, "run_as_user"); + } + if !process.run_as_group.is_empty() { + add_process_identity_loss(items, "run_as_group"); + } + } + + if opts.containment == "processcontainer" + && let Some(fs) = &policy.filesystem + { + let any_linux_path = fs + .read_only + .iter() + .chain(fs.read_write.iter()) + .any(|p| p.starts_with('/')); + if any_linux_path { + add_loss( + items, + "filesystem_policy", + "warning", + "OpenShell example paths are Linux paths; Windows ProcessContainer expects Windows paths.", + "filesystem path syntax", + "Run with path translation or target a Linux-like MXC backend.", + ); + } + } +} + +fn add_process_identity_loss(items: &mut Vec, field: &str) { + add_loss( + items, + &format!("process.{field}"), + "warning", + &format!("MXC has no portable equivalent for OpenShell {field}."), + "process identity", + "MXC backend identity is selected outside this policy mapping.", + ); +} + +fn append_unique(list: &mut Vec, value: String) { + if !list.contains(&value) { + list.push(value); + } +} + +fn contains_wildcard(host: &str) -> bool { + host.contains('*') +} diff --git a/crates/openshell-driver-mxc/src/policy_map/mod.rs b/crates/openshell-driver-mxc/src/policy_map/mod.rs new file mode 100644 index 0000000000..52f8b4ee9e --- /dev/null +++ b/crates/openshell-driver-mxc/src/policy_map/mod.rs @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Map an `OpenShell` sandbox policy to a Microsoft MXC `ContainerConfig`. +//! +//! This module is the **source of truth** for the OpenShell→MXC policy mapping +//! (it was the standalone `openshell-policy-mapper` crate). It is embedded in the +//! MXC driver as a module, consumed by the [`crate::policy`] seam. +//! +//! It reuses the canonical typed [`SandboxPolicy`] from `openshell-policy` +//! (obtained via `openshell_policy::parse_sandbox_policy`) rather than re-parsing +//! YAML, so the policy schema has a single source of truth. +//! +//! Two mapping shapes are intended: +//! +//! - [`map_to_mxc`] — the *coarse / standalone* mapping. `OpenShell` network +//! policy is flattened into an MXC host allowlist (`network.allowedHosts`), +//! and anything MXC cannot express (ports, protocol, L7 rules, binary scope) +//! is recorded in the loss report. Use this when MXC enforces network on its +//! own, with no `OpenShell` proxy in the loop. +//! - `split_policy` (added in a later sprint) — the *lossless* split for the +//! Windows MXC compute driver: MXC handles filesystem + containment + a +//! `network.proxy` redirect, while the full `OpenShell` network policy is +//! preserved in a trimmed policy enforced by the host CONNECT proxy. +//! +//! The report/loss-report helpers are only exercised by the example and the +//! integration tests, so the Windows lib build would otherwise warn on them; +//! `#![allow(dead_code)]` keeps the module quiet without per-item churn. +//! +//! [`SandboxPolicy`]: openshell_core::proto::SandboxPolicy + +#![allow(dead_code)] + +mod config; +mod loss; +mod map; +mod report; + +pub use config::{DEFAULT_COMMAND, DEFAULT_CONTAINMENT, DEFAULT_MXC_VERSION}; +pub use loss::{LossItem, OPEN_SHELL_SUPERSET_GAPS}; +pub use map::{MxcMappingOptions, MxcMappingResult, map_to_mxc}; +pub use report::{build_loss_report, render_readme}; diff --git a/crates/openshell-driver-mxc/src/policy_map/report.rs b/crates/openshell-driver-mxc/src/policy_map/report.rs new file mode 100644 index 0000000000..0b1ec37b77 --- /dev/null +++ b/crates/openshell-driver-mxc/src/policy_map/report.rs @@ -0,0 +1,140 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Loss-report JSON and human-readable README rendering. + +use serde_json::{Value, json}; + +use super::loss::{LossItem, OPEN_SHELL_SUPERSET_GAPS, summarize_missing_mxc}; + +/// Build the structured `loss-report.json` value. +pub fn build_loss_report( + source_policy: &str, + generated_config: &str, + items: &[LossItem], + schema_errors: &[LossItem], + mxc_version: &str, + containment: &str, + schema: Option<&str>, +) -> Value { + let count = |severity: &str| items.iter().filter(|i| i.severity == severity).count(); + + json!({ + "sourcePolicy": source_policy, + "generatedConfig": generated_config, + "target": { + "schemaVersion": mxc_version, + "containment": containment, + }, + "schemaValidation": { + "schema": schema, + "valid": schema_errors.is_empty(), + }, + "lossy": !items.is_empty(), + "counts": { + "error": count("error"), + "warning": count("warning"), + "info": count("info"), + }, + "items": items, + "openShellFieldsNotInMxc": summarize_missing_mxc(items), + "mxcFieldsNotInOpenShellPolicy": OPEN_SHELL_SUPERSET_GAPS, + }) +} + +/// Render the human-readable `README.md` summarizing the mapping. +pub fn render_readme(source_policy: &str, report: &Value, config: &Value) -> String { + let container_id = config["containerId"].as_str().unwrap_or(""); + let containment = config["containment"].as_str().unwrap_or(""); + let schema_valid = report["schemaValidation"]["valid"] + .as_bool() + .unwrap_or(false); + + let join_strs = |value: &Value| -> String { + let parts: Vec<&str> = value + .as_array() + .map(|a| a.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + if parts.is_empty() { + "(none)".to_owned() + } else { + parts.join(", ") + } + }; + + let allowed = join_strs(&config["network"]["allowedHosts"]); + let rw = join_strs(&config["filesystem"]["readwritePaths"]); + let ro = join_strs(&config["filesystem"]["readonlyPaths"]); + + let mut lines: Vec = vec![ + format!("# {container_id}"), + String::new(), + "## Generated Files".to_owned(), + String::new(), + format!( + "- `mxc-config.json`: direct MXC `ContainerConfig` generated from `{source_policy}`." + ), + "- `loss-report.json`: structured mapping loss report.".to_owned(), + String::new(), + "## MXC Consumption".to_owned(), + String::new(), + "The generated config is intended to be consumable by MXC's direct JSON path.".to_owned(), + "It uses a harmless placeholder `process.commandLine`; replace it with the".to_owned(), + "real workload command before running anything meaningful.".to_owned(), + String::new(), + format!("- Containment: `{containment}`"), + format!("- Schema validation: `{schema_valid}`"), + format!("- Allowed hosts: `{allowed}`"), + format!("- Read-write paths: `{rw}`"), + format!("- Read-only paths: `{ro}`"), + String::new(), + "## Missing In MXC For This OpenShell Policy".to_owned(), + String::new(), + ]; + + let notable: Vec<&Value> = report["items"] + .as_array() + .map(|a| { + a.iter() + .filter(|item| matches!(item["severity"].as_str(), Some("error" | "warning"))) + .collect() + }) + .unwrap_or_default(); + + if notable.is_empty() { + lines.push("- No lossy OpenShell-to-MXC policy mappings were detected.".to_owned()); + } else { + for item in notable { + lines.push(format!( + "- `{}` `{}`: {} Impact: {}", + item["severity"].as_str().unwrap_or(""), + item["path"].as_str().unwrap_or(""), + item["message"].as_str().unwrap_or(""), + item["mxc_impact"].as_str().unwrap_or(""), + )); + } + } + + lines.extend([ + String::new(), + "## Missing In OpenShell Policy For MXC".to_owned(), + String::new(), + ]); + if let Some(gaps) = report["mxcFieldsNotInOpenShellPolicy"].as_array() { + for gap in gaps.iter().filter_map(Value::as_str) { + lines.push(format!("- {gap}")); + } + } + + lines.extend([ + String::new(), + "## Notes".to_owned(), + String::new(), + "- OpenShell network policies are binary-, port-, protocol-, and often L7-scoped.".to_owned(), + "- This coarse mapper emits only MXC host/IP/CIDR allowlists plus filesystem lists.".to_owned(), + "- Treat any `error` item in the loss report as a semantic broadening or unsupported parity gap.".to_owned(), + String::new(), + ]); + + lines.join("\n") +} diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs b/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs new file mode 100644 index 0000000000..be406cec31 --- /dev/null +++ b/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs @@ -0,0 +1,195 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Parity/invariant tests for the embedded coarse map over the repository's +//! example policies. +//! +//! Windows-only: the embedded mapper API is gated on `target_os = "windows"`, +//! so this whole file compiles to nothing elsewhere and runs in full on a +//! Windows test lane. +//! +//! Byte-for-byte parity with the previous raw-YAML mapper is intentionally +//! *not* asserted: routing through the canonical typed `SandboxPolicy` +//! normalizes ports and uses an unordered proto map (we sort keys). Instead we +//! assert the substantive invariants — filesystem fidelity, the host allowlist, +//! deny-by-default, and that broadening features are flagged as losses. + +#![cfg(target_os = "windows")] + +use std::path::{Path, PathBuf}; + +use openshell_driver_mxc::{MxcMappingOptions, map_to_mxc}; +use openshell_policy::parse_sandbox_policy; +use serde_json::Value; + +fn examples_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples") +} + +fn discover(dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + discover(&path, out); + } else if let Some(name) = path.file_name().and_then(|n| n.to_str()) + && name.contains("policy") + && path + .extension() + .is_some_and(|e| e.eq_ignore_ascii_case("yaml")) + { + out.push(path); + } + } +} + +fn str_list(value: &Value) -> Vec { + value + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(str::to_owned)) + .collect() + }) + .unwrap_or_default() +} + +#[test] +fn all_example_policies_map_with_invariants() { + let root = examples_root(); + let mut policies = Vec::new(); + discover(&root, &mut policies); + policies.sort(); + assert!( + !policies.is_empty(), + "no example policies found under {}", + root.display() + ); + + for path in &policies { + let yaml = std::fs::read_to_string(path).expect("read policy"); + let policy = parse_sandbox_policy(&yaml) + .unwrap_or_else(|e| panic!("parse {} failed: {e}", path.display())); + + let result = map_to_mxc(&policy, &MxcMappingOptions::default()); + let cfg = &result.config; + + // Deny-by-default network posture is always emitted. + assert_eq!( + cfg["network"]["defaultPolicy"], + "block", + "{} must emit defaultPolicy=block", + path.display() + ); + + // Filesystem fidelity: read_write / read_only copied exactly. + if let Some(fs) = &policy.filesystem { + assert_eq!( + str_list(&cfg["filesystem"]["readwritePaths"]), + fs.read_write, + "readwrite mismatch for {}", + path.display() + ); + assert_eq!( + str_list(&cfg["filesystem"]["readonlyPaths"]), + fs.read_only, + "readonly mismatch for {}", + path.display() + ); + } + + // Every non-wildcard endpoint host appears in allowedHosts. + let allowed = str_list(&cfg["network"]["allowedHosts"]); + for rule in policy.network_policies.values() { + for ep in &rule.endpoints { + if !ep.host.is_empty() && !ep.host.contains('*') { + assert!( + allowed.contains(&ep.host), + "{} missing host {} in allowedHosts", + path.display(), + ep.host + ); + } + } + } + + // allowedHosts is deduplicated. + let mut sorted = allowed.clone(); + sorted.sort(); + sorted.dedup(); + assert_eq!( + sorted.len(), + allowed.len(), + "duplicate hosts for {}", + path.display() + ); + + // Deterministic: mapping twice yields identical config. + let again = map_to_mxc(&policy, &MxcMappingOptions::default()); + assert_eq!( + result.config, + again.config, + "non-deterministic for {}", + path.display() + ); + } +} + +#[test] +fn quickstart_coarse_mapping() { + let path = examples_root().join("sandbox-policy-quickstart/policy.yaml"); + let yaml = std::fs::read_to_string(&path).expect("read quickstart"); + let policy = parse_sandbox_policy(&yaml).expect("parse quickstart"); + let result = map_to_mxc(&policy, &MxcMappingOptions::default()); + let cfg = &result.config; + + assert_eq!( + str_list(&cfg["network"]["allowedHosts"]), + vec!["api.github.com".to_owned()] + ); + assert_eq!( + str_list(&cfg["filesystem"]["readwritePaths"]), + vec!["/sandbox", "/tmp", "/dev/null"] + ); + assert_eq!(cfg["containment"], "bubblewrap"); + + // The github_api endpoint loses port, protocol, access, and binary scope. + let has = |severity: &str, needle: &str| { + result + .loss + .iter() + .any(|i| i.severity == severity && i.path.contains(needle)) + }; + assert!(has("error", "endpoints[0].port"), "expected port loss"); + assert!( + has("error", "endpoints[0].protocol"), + "expected protocol loss" + ); + assert!( + has("error", "endpoints[0].access"), + "expected access preset loss" + ); + assert!( + has("error", "binaries[0].path"), + "expected binary-scope loss" + ); +} + +#[test] +fn network_only_policy_has_empty_filesystem() { + // policy-advisor is a network-only seed (no filesystem_policy). + let path = examples_root().join("policy-advisor/sandbox-policy.yaml"); + let yaml = std::fs::read_to_string(&path).expect("read policy-advisor"); + let policy = parse_sandbox_policy(&yaml).expect("parse policy-advisor"); + let result = map_to_mxc(&policy, &MxcMappingOptions::default()); + let cfg = &result.config; + + assert!(str_list(&cfg["filesystem"]["readwritePaths"]).is_empty()); + assert!(str_list(&cfg["filesystem"]["readonlyPaths"]).is_empty()); + assert_eq!( + str_list(&cfg["network"]["allowedHosts"]), + vec!["api.anthropic.com".to_owned()] + ); +} From 4b63f908955ef71a1793bacda08e653d4e0e13a5 Mon Sep 17 00:00:00 2001 From: Giedrius Burachas Date: Tue, 9 Jun 2026 20:20:17 -0700 Subject: [PATCH 08/19] feat(driver-mxc): implement lossless split_policy for proxy-delegated egress Signed-off-by: Giedrius Burachas (cherry picked from commit 96d6afa0e2dc6a1d54edd12c34a0ceb0a30dadd0) Signed-off-by: Jamie King --- .../examples/policy-to-mxc.rs | 96 +++++++++++++++- crates/openshell-driver-mxc/src/lib.rs | 3 +- .../src/policy_map/map.rs | 103 ++++++++++++++++++ .../src/policy_map/mod.rs | 10 +- .../tests/policy_mapper_examples.rs | 79 +++++++++++++- 5 files changed, 283 insertions(+), 8 deletions(-) diff --git a/crates/openshell-driver-mxc/examples/policy-to-mxc.rs b/crates/openshell-driver-mxc/examples/policy-to-mxc.rs index 0feb01434f..9b34f055a2 100644 --- a/crates/openshell-driver-mxc/examples/policy-to-mxc.rs +++ b/crates/openshell-driver-mxc/examples/policy-to-mxc.rs @@ -29,7 +29,7 @@ mod imp { use clap::Parser; use openshell_driver_mxc::{ DEFAULT_COMMAND, DEFAULT_CONTAINMENT, DEFAULT_MXC_VERSION, LossItem, MxcMappingOptions, - build_loss_report, map_to_mxc, render_readme, + build_loss_report, map_to_mxc, render_readme, split_policy, }; use serde_json::Value; @@ -81,11 +81,41 @@ mod imp { /// Emit `OpenShell` wildcard hosts into `allowedHosts` despite lossiness. #[arg(long)] allow_wildcards: bool, + + /// Run the lossless split instead of the coarse map. + /// + /// Requires `--proxy-port`. Prints the MXC config (with proxy redirect + /// and empty `allowedHosts`) and the trimmed proxy policy side-by-side. + #[arg(long)] + split: bool, + + /// Localhost port the `OpenShell` CONNECT proxy listens on (required with `--split`). + #[arg(long)] + proxy_port: Option, } pub fn run() -> Result<()> { let args = Args::parse(); + if args.split { + let port = args + .proxy_port + .ok_or_else(|| anyhow!("--proxy-port is required with --split"))?; + let policy_path = args + .policy + .as_ref() + .ok_or_else(|| anyhow!("--policy is required with --split"))?; + let stem = policy_path + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + let slug = args.container_id.clone().unwrap_or(stem); + let mut opts = build_options(&args, &slug); + opts.proxy_localhost_port = Some(port); + return show_split(policy_path, &opts); + } + if let Some(examples_root) = &args.examples_root { let mut examples = discover_example_policies(examples_root)?; if examples.is_empty() { @@ -211,6 +241,70 @@ mod imp { Ok(()) } + // ----------------------------------------------------------------------- + // Lossless-split display + // ----------------------------------------------------------------------- + + fn show_split(policy_path: &Path, opts: &MxcMappingOptions) -> Result<()> { + let content = std::fs::read_to_string(policy_path) + .with_context(|| format!("reading {}", policy_path.display()))?; + let policy = openshell_policy::parse_sandbox_policy(&content) + .map_err(|e| anyhow!("parsing {}: {e}", policy_path.display()))?; + + let result = split_policy(&policy, opts) + .ok_or_else(|| anyhow!("split_policy returned None — is --proxy-port set?"))?; + + println!("=== MXC ContainerConfig (filesystem + proxy redirect) ==="); + println!("{}", serde_json::to_string_pretty(&result.mxc_config)?); + + println!(); + println!("=== Proxy policy (preserved for OpenShell fine-grained enforcement) ==="); + if result.proxy_policy.network_policies.is_empty() { + println!(" (no network_policies — nothing for the proxy to enforce)"); + } else { + let mut rules: Vec<_> = result.proxy_policy.network_policies.iter().collect(); + rules.sort_by_key(|(k, _)| k.as_str()); + for (name, rule) in rules { + println!(" rule: {name}"); + for ep in &rule.endpoints { + let ports = if ep.ports.is_empty() { + String::new() + } else { + format!(":{}", ep.ports.iter().map(|p| p.to_string()).collect::>().join(",")) + }; + println!( + " endpoint: {}{} protocol={} tls={} access={} enforcement={}", + ep.host, ports, + if ep.protocol.is_empty() { "-" } else { &ep.protocol }, + if ep.tls.is_empty() { "-" } else { &ep.tls }, + if ep.access.is_empty() { "-" } else { &ep.access }, + if ep.enforcement.is_empty() { "-" } else { &ep.enforcement }, + ); + if !ep.rules.is_empty() { + println!(" allow rules: {}", ep.rules.len()); + } + if !ep.deny_rules.is_empty() { + println!(" deny rules: {}", ep.deny_rules.len()); + } + } + let binaries: Vec<_> = rule.binaries.iter().map(|b| b.path.as_str()).collect(); + if !binaries.is_empty() { + println!(" binaries: {}", binaries.join(", ")); + } + } + } + + if !result.loss.is_empty() { + println!(); + println!("=== Filesystem loss items ({} item(s)) ===", result.loss.len()); + for item in &result.loss { + println!(" [{}] {}: {}", item.severity, item.path, item.message); + } + } + + Ok(()) + } + // ----------------------------------------------------------------------- // File discovery & slug helpers // ----------------------------------------------------------------------- diff --git a/crates/openshell-driver-mxc/src/lib.rs b/crates/openshell-driver-mxc/src/lib.rs index 3a0c03e9de..0137e622b8 100644 --- a/crates/openshell-driver-mxc/src/lib.rs +++ b/crates/openshell-driver-mxc/src/lib.rs @@ -37,5 +37,6 @@ pub use grpc::ComputeDriverService; #[cfg(target_os = "windows")] pub use policy_map::{ DEFAULT_COMMAND, DEFAULT_CONTAINMENT, DEFAULT_MXC_VERSION, LossItem, MxcMappingOptions, - MxcMappingResult, OPEN_SHELL_SUPERSET_GAPS, build_loss_report, map_to_mxc, render_readme, + MxcMappingResult, OPEN_SHELL_SUPERSET_GAPS, SplitPolicyResult, build_loss_report, map_to_mxc, + render_readme, split_policy, }; diff --git a/crates/openshell-driver-mxc/src/policy_map/map.rs b/crates/openshell-driver-mxc/src/policy_map/map.rs index 6ec25704b4..cf690cc3d8 100644 --- a/crates/openshell-driver-mxc/src/policy_map/map.rs +++ b/crates/openshell-driver-mxc/src/policy_map/map.rs @@ -67,6 +67,26 @@ pub struct MxcMappingResult { pub loss: Vec, } +/// Result of the lossless split: the MXC config carries filesystem grants and a +/// proxy redirect; the full network policy is returned unchanged for the +/// `OpenShell` CONNECT proxy to enforce. +#[derive(Clone, Debug)] +pub struct SplitPolicyResult { + /// MXC `ContainerConfig` with filesystem grants and `network.proxy` redirect. + /// + /// `network.allowedHosts` is empty — direct egress is blocked at the MXC + /// layer. All outbound connections flow through the proxy; the proxy enforces + /// the full `OpenShell` network policy. + pub mxc_config: Value, + /// Full `OpenShell` network policy preserved verbatim for the host CONNECT + /// proxy. Only `network_policies` is populated; the proxy does not enforce + /// filesystem rules. + pub proxy_policy: SandboxPolicy, + /// Loss items from the filesystem side only. Network rules produce no losses + /// here — they are delegated to the proxy rather than approximated. + pub loss: Vec, +} + /// Map an `OpenShell` policy to a coarse MXC `ContainerConfig`. pub fn map_to_mxc(policy: &SandboxPolicy, opts: &MxcMappingOptions) -> MxcMappingResult { let mut loss = Vec::new(); @@ -74,6 +94,89 @@ pub fn map_to_mxc(policy: &SandboxPolicy, opts: &MxcMappingOptions) -> MxcMappin MxcMappingResult { config, loss } } +/// Lossless split: map filesystem + containment to MXC, delegate network to the +/// `OpenShell` CONNECT proxy. +/// +/// The returned [`SplitPolicyResult::mxc_config`] sets `network.proxy` to +/// `127.0.0.1:{proxy_localhost_port}` and leaves `allowedHosts` empty — direct +/// egress is blocked at the MXC layer and all outbound connections flow through +/// the proxy. [`SplitPolicyResult::proxy_policy`] carries the original +/// `network_policies` verbatim; no binary-scope, port, protocol, or wildcard +/// loss items are generated for the network side. +/// +/// Returns `None` if `opts.proxy_localhost_port` is not set. Use [`map_to_mxc`] +/// for the standalone coarse path when no proxy is in the loop. +pub fn split_policy(policy: &SandboxPolicy, opts: &MxcMappingOptions) -> Option { + let port = opts.proxy_localhost_port?; + let mut loss = Vec::new(); + let mxc_config = build_split_mxc_config(policy, opts, port, &mut loss); + let proxy_policy = SandboxPolicy { + network_policies: policy.network_policies.clone(), + ..Default::default() + }; + Some(SplitPolicyResult { + mxc_config, + proxy_policy, + loss, + }) +} + +fn build_split_mxc_config( + policy: &SandboxPolicy, + opts: &MxcMappingOptions, + proxy_port: u16, + items: &mut Vec, +) -> Value { + let mut process = json!({ + "commandLine": opts.command, + "timeout": opts.timeout_ms, + }); + if let Some(cwd) = &opts.cwd { + process["cwd"] = json!(cwd); + } + if !opts.env.is_empty() { + process["env"] = json!(opts.env); + } + + let filesystem = map_filesystem(policy, opts, items); + + // Direct egress is blocked; all outbound flows through the OpenShell proxy. + // allowedHosts is intentionally empty — the proxy enforces the full policy. + let network = json!({ + "defaultPolicy": "block", + "allowedHosts": [], + "blockedHosts": [], + "proxy": { + "host": "127.0.0.1", + "port": proxy_port, + }, + }); + + let mut config = json!({ + "version": opts.mxc_version, + "containerId": opts.container_id, + "containment": opts.containment, + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false, + }, + "process": process, + "filesystem": filesystem, + "network": network, + "ui": { + "disable": true, + "clipboard": "none", + "injection": false, + }, + }); + + // No network hosts, so backend-specific network blocks (processContainer + // internetClient, etc.) are not added — correct for the proxy path. + add_backend_specific_config(&mut config, &opts.containment, &[], items); + add_static_policy_loss(policy, opts, items); + config +} + fn build_mxc_config( policy: &SandboxPolicy, opts: &MxcMappingOptions, diff --git a/crates/openshell-driver-mxc/src/policy_map/mod.rs b/crates/openshell-driver-mxc/src/policy_map/mod.rs index 52f8b4ee9e..52dc7f186c 100644 --- a/crates/openshell-driver-mxc/src/policy_map/mod.rs +++ b/crates/openshell-driver-mxc/src/policy_map/mod.rs @@ -18,10 +18,10 @@ //! and anything MXC cannot express (ports, protocol, L7 rules, binary scope) //! is recorded in the loss report. Use this when MXC enforces network on its //! own, with no `OpenShell` proxy in the loop. -//! - `split_policy` (added in a later sprint) — the *lossless* split for the -//! Windows MXC compute driver: MXC handles filesystem + containment + a -//! `network.proxy` redirect, while the full `OpenShell` network policy is -//! preserved in a trimmed policy enforced by the host CONNECT proxy. +//! - [`split_policy`] — the *lossless* split for the Windows MXC compute +//! driver: MXC handles filesystem + containment + a `network.proxy` redirect, +//! while the full `OpenShell` network policy is preserved in a trimmed policy +//! enforced by the host CONNECT proxy. //! //! The report/loss-report helpers are only exercised by the example and the //! integration tests, so the Windows lib build would otherwise warn on them; @@ -38,5 +38,5 @@ mod report; pub use config::{DEFAULT_COMMAND, DEFAULT_CONTAINMENT, DEFAULT_MXC_VERSION}; pub use loss::{LossItem, OPEN_SHELL_SUPERSET_GAPS}; -pub use map::{MxcMappingOptions, MxcMappingResult, map_to_mxc}; +pub use map::{MxcMappingOptions, MxcMappingResult, SplitPolicyResult, map_to_mxc, split_policy}; pub use report::{build_loss_report, render_readme}; diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs b/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs index be406cec31..344d8b4ad6 100644 --- a/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs +++ b/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs @@ -18,7 +18,7 @@ use std::path::{Path, PathBuf}; -use openshell_driver_mxc::{MxcMappingOptions, map_to_mxc}; +use openshell_driver_mxc::{MxcMappingOptions, map_to_mxc, split_policy}; use openshell_policy::parse_sandbox_policy; use serde_json::Value; @@ -177,6 +177,83 @@ fn quickstart_coarse_mapping() { ); } +#[test] +fn split_policy_routes_network_to_proxy() { + let path = examples_root().join("sandbox-policy-quickstart/policy.yaml"); + let yaml = std::fs::read_to_string(&path).expect("read quickstart"); + let policy = parse_sandbox_policy(&yaml).expect("parse quickstart"); + + let opts = MxcMappingOptions { + proxy_localhost_port: Some(8080), + ..Default::default() + }; + let result = split_policy(&policy, &opts).expect("split_policy returns Some when port is set"); + let cfg = &result.mxc_config; + + // Proxy redirect is emitted. + assert_eq!(cfg["network"]["proxy"]["host"], "127.0.0.1"); + assert_eq!(cfg["network"]["proxy"]["port"], 8080); + + // Direct egress is blocked; allowedHosts is empty (proxy enforces the list). + assert_eq!(cfg["network"]["defaultPolicy"], "block"); + assert!( + str_list(&cfg["network"]["allowedHosts"]).is_empty(), + "split path must not populate allowedHosts" + ); + + // Filesystem grants are preserved unchanged. + assert_eq!( + str_list(&cfg["filesystem"]["readwritePaths"]), + policy.filesystem.as_ref().unwrap().read_write + ); + + // Network policy is returned verbatim for the proxy. + assert_eq!( + result.proxy_policy.network_policies.len(), + policy.network_policies.len(), + "proxy_policy must carry all network rules" + ); + assert!( + result.proxy_policy.filesystem.is_none(), + "proxy_policy must not carry filesystem rules" + ); + + // No binary-scope, port, or protocol losses — those are delegated to the proxy. + let net_losses: Vec<_> = result + .loss + .iter() + .filter(|i| i.path.starts_with("network_policies")) + .collect(); + assert!( + net_losses.is_empty(), + "split path must not generate network losses: {net_losses:?}" + ); +} + +#[test] +fn split_policy_returns_none_without_port() { + let opts = MxcMappingOptions::default(); + let policy = parse_sandbox_policy("").unwrap_or_default(); + assert!( + split_policy(&policy, &opts).is_none(), + "split_policy must return None when proxy_localhost_port is not set" + ); +} + +#[test] +fn split_policy_deterministic() { + let path = examples_root().join("sandbox-policy-quickstart/policy.yaml"); + let yaml = std::fs::read_to_string(&path).expect("read quickstart"); + let policy = parse_sandbox_policy(&yaml).expect("parse quickstart"); + let opts = MxcMappingOptions { + proxy_localhost_port: Some(9999), + ..Default::default() + }; + let a = split_policy(&policy, &opts).unwrap(); + let b = split_policy(&policy, &opts).unwrap(); + assert_eq!(a.mxc_config, b.mxc_config, "split_policy must be deterministic"); +} + #[test] fn network_only_policy_has_empty_filesystem() { // policy-advisor is a network-only seed (no filesystem_policy). From 76957ffe9cd02c5f43da73041bdcdd70accaab62 Mon Sep 17 00:00:00 2001 From: Giedrius Burachas Date: Thu, 11 Jun 2026 17:44:08 -0700 Subject: [PATCH 09/19] feat(driver-mxc): implement Pattern-C governed-egress split through the policy seam - split_policy: SocketAddr proxy_redirect (replaces bare port), processcontainer containment guard naming MXC M1, version preserved in the trimmed proxy_policy, delegation reported as an info loss item - seam: MappedConfig carries trimmed_policy + proxy_addr; MapCtx.egress selects the split path; coarse path unchanged when egress is disabled - driver: [openshell.drivers.mxc] egress_proxy / egress_proxy_addr config, validated at create (isolation_session rejected until M1); lifecycle threads the redirect into provision and stores the trimmed policy per sandbox, emitting an EgressRedirect platform event - mxc: optional MxcNetwork block (defaultPolicy=block + proxy) in provision and one-shot configs; mock records configs for test assertions - tests: lossless-invariant suite over all example policies (validate + serialize round-trip), split lifecycle proof, M1 rejection; example gains --split --proxy-addr writing mxc-config.json / trimmed-policy.yaml / loss-report.json Signed-off-by: Giedrius Burachas (cherry picked from commit 34d54ad9f25dc6034c3ba15668555ff0d22cddd8) Signed-off-by: Jamie King --- crates/openshell-driver-mxc/README.md | 22 +- .../examples/policy-to-mxc.rs | 100 +++++- crates/openshell-driver-mxc/src/driver.rs | 210 +++++++++++- crates/openshell-driver-mxc/src/mxc.rs | 307 ++++++++++++++++-- crates/openshell-driver-mxc/src/policy.rs | 105 +++++- .../src/policy_map/map.rs | 62 +++- .../tests/policy_mapper_examples.rs | 146 ++++++++- 7 files changed, 858 insertions(+), 94 deletions(-) diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 27c3754ea0..70027f6bc2 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -18,8 +18,8 @@ architectural rationale (decisions D1–D4). | Capability | MXC driver | Closing it requires | |---|---|---| | Filesystem policy (read-write / read-only grants) | ✅ provision-time AppContainer shares | — | -| Governed egress (CONNECT proxy + OPA + L7) | ❌ | `implement-openshell-mxc-egress-proxy` | -| Network policy | ❌ `isolation_session` rejects network config | MXC feedback item M1 + egress skill | +| Governed egress (CONNECT proxy + OPA + L7) | Available behind `egress_proxy` on `process_container`; host proxy integration is the next consumer | `implement-openshell-mxc-egress-proxy` | +| Network policy | Split into MXC `network.proxy` + trimmed OpenShell policy on `process_container`; `isolation_session` still rejects network config | MXC feedback item M1 for persistent sessions | | Process policy (seccomp, uid/gid) | ❌ host-side governance design; OS isolation only | not pursued | | Interactive exec/connect/forward | ❌ exec runs in-driver, no client attach | `adapt-openshell-gateway-windows` | | Bundled agent image | ❌ no OCI image; relies on Windows host install | — | @@ -37,14 +37,23 @@ The June 15 demo proof point is **filesystem policy enforcement**: [openshell.drivers.mxc] # Path to wxc-exec.exe (required for live runs) wxc_exec_path = "C:\\path\\to\\wxc-exec.exe" +# MXC backend: "isolation_session" (default) or "process_container" +backend = "process_container" # MXC configurationId — never use "small" (known OS bug) default_configuration_id = "composable" +# process_container-only options +pc_least_privilege = false +pc_capabilities = [] # Agent command executed inside the sandbox agent_command = ["cmd", "/c", "echo hello > C:\\work\\demo\\hello.txt"] # Working directory for the agent (defaults to share_dir) agent_cwd = "C:\\work\\demo" # Host directory mapped read-write into the sandbox share_dir = "C:\\work\\demo" +# Pattern C governed egress. Requires backend = "process_container" until +# MXC M1 adds network.proxy support for isolation_session. +egress_proxy = false +egress_proxy_addr = "" # Enable --debug on wxc-exec invocations debug = false ``` @@ -76,6 +85,13 @@ the **source of truth** for the OpenShell→MXC mapping — it was the standalon `StubPolicyMapper` is retained as a documented, compile-only fallback that only maps `share_dir`. +When `egress_proxy` is enabled, `EmbeddedPolicyMapper` uses `split_policy` +instead: MXC receives filesystem grants plus a loopback `network.proxy` +redirect, and the driver stores the trimmed network-only `SandboxPolicy` for +the host CONNECT proxy. The development export surface remains the +[`policy-to-mxc`](examples/policy-to-mxc.rs) example; there is no production +`openshell policy export-mxc` subcommand yet. + Everything in this crate — including the mapper, the [`policy-to-mxc`](examples/policy-to-mxc.rs) example, and the parity tests in [`tests/policy_mapper_examples.rs`](tests/policy_mapper_examples.rs) — is @@ -95,6 +111,6 @@ landed before moving it. ## Deferred work - **Interactive exec/connect/forward** → `adapt-openshell-gateway-windows` -- **Governed egress / network policy** → `implement-openshell-mxc-egress-proxy` +- **Governed egress proxy implementation** → `implement-openshell-mxc-egress-proxy` - **Restart durability** (deprovision orphaned sessions on startup) → follow-on - **GPU passthrough** → not pursued in host-side-governance design diff --git a/crates/openshell-driver-mxc/examples/policy-to-mxc.rs b/crates/openshell-driver-mxc/examples/policy-to-mxc.rs index 9b34f055a2..3f90d951a7 100644 --- a/crates/openshell-driver-mxc/examples/policy-to-mxc.rs +++ b/crates/openshell-driver-mxc/examples/policy-to-mxc.rs @@ -23,6 +23,7 @@ fn main() {} #[cfg(target_os = "windows")] mod imp { + use std::net::SocketAddr; use std::path::{Path, PathBuf}; use anyhow::{Context, Result, anyhow, bail}; @@ -84,12 +85,17 @@ mod imp { /// Run the lossless split instead of the coarse map. /// - /// Requires `--proxy-port`. Prints the MXC config (with proxy redirect - /// and empty `allowedHosts`) and the trimmed proxy policy side-by-side. + /// Requires `--proxy-addr`. Prints and writes the MXC config (with proxy + /// redirect and empty `allowedHosts`) plus the trimmed proxy policy. #[arg(long)] split: bool, - /// Localhost port the `OpenShell` CONNECT proxy listens on (required with `--split`). + /// Loopback address the `OpenShell` CONNECT proxy listens on. Accepts + /// `IP:PORT`, or a bare port shorthand expanded to `127.0.0.1:PORT`. + #[arg(long)] + proxy_addr: Option, + + /// Deprecated shorthand for `--proxy-addr 127.0.0.1:PORT`. #[arg(long)] proxy_port: Option, } @@ -98,9 +104,7 @@ mod imp { let args = Args::parse(); if args.split { - let port = args - .proxy_port - .ok_or_else(|| anyhow!("--proxy-port is required with --split"))?; + let proxy_addr = parse_proxy_addr(args.proxy_addr.as_deref(), args.proxy_port)?; let policy_path = args .policy .as_ref() @@ -112,8 +116,8 @@ mod imp { .into_owned(); let slug = args.container_id.clone().unwrap_or(stem); let mut opts = build_options(&args, &slug); - opts.proxy_localhost_port = Some(port); - return show_split(policy_path, &opts); + opts.proxy_redirect = Some(proxy_addr); + return show_split(policy_path, &args.out_dir, &opts); } if let Some(examples_root) = &args.examples_root { @@ -173,10 +177,27 @@ mod imp { env: args.env.clone(), timeout_ms: args.timeout_ms, allow_wildcards: args.allow_wildcards, - proxy_localhost_port: None, + proxy_redirect: None, } } + fn parse_proxy_addr(addr: Option<&str>, port: Option) -> Result { + let Some(raw) = addr else { + let port = port.ok_or_else(|| anyhow!("--proxy-addr is required with --split"))?; + return format!("127.0.0.1:{port}") + .parse() + .context("parsing deprecated --proxy-port shorthand"); + }; + let expanded = if raw.chars().all(|c| c.is_ascii_digit()) { + format!("127.0.0.1:{raw}") + } else { + raw.to_owned() + }; + expanded + .parse() + .with_context(|| format!("parsing --proxy-addr {raw:?}")) + } + fn convert_policy( policy_path: &Path, out_dir: &Path, @@ -245,14 +266,15 @@ mod imp { // Lossless-split display // ----------------------------------------------------------------------- - fn show_split(policy_path: &Path, opts: &MxcMappingOptions) -> Result<()> { + fn show_split(policy_path: &Path, out_dir: &Path, opts: &MxcMappingOptions) -> Result<()> { let content = std::fs::read_to_string(policy_path) .with_context(|| format!("reading {}", policy_path.display()))?; let policy = openshell_policy::parse_sandbox_policy(&content) .map_err(|e| anyhow!("parsing {}: {e}", policy_path.display()))?; let result = split_policy(&policy, opts) - .ok_or_else(|| anyhow!("split_policy returned None — is --proxy-port set?"))?; + .ok_or_else(|| anyhow!("split_policy returned None; is --proxy-addr set?"))?; + write_split_outputs(policy_path, out_dir, opts, &result)?; println!("=== MXC ContainerConfig (filesystem + proxy redirect) ==="); println!("{}", serde_json::to_string_pretty(&result.mxc_config)?); @@ -270,15 +292,35 @@ mod imp { let ports = if ep.ports.is_empty() { String::new() } else { - format!(":{}", ep.ports.iter().map(|p| p.to_string()).collect::>().join(",")) + format!( + ":{}", + ep.ports + .iter() + .map(|p| p.to_string()) + .collect::>() + .join(",") + ) }; println!( " endpoint: {}{} protocol={} tls={} access={} enforcement={}", - ep.host, ports, - if ep.protocol.is_empty() { "-" } else { &ep.protocol }, + ep.host, + ports, + if ep.protocol.is_empty() { + "-" + } else { + &ep.protocol + }, if ep.tls.is_empty() { "-" } else { &ep.tls }, - if ep.access.is_empty() { "-" } else { &ep.access }, - if ep.enforcement.is_empty() { "-" } else { &ep.enforcement }, + if ep.access.is_empty() { + "-" + } else { + &ep.access + }, + if ep.enforcement.is_empty() { + "-" + } else { + &ep.enforcement + }, ); if !ep.rules.is_empty() { println!(" allow rules: {}", ep.rules.len()); @@ -296,7 +338,10 @@ mod imp { if !result.loss.is_empty() { println!(); - println!("=== Filesystem loss items ({} item(s)) ===", result.loss.len()); + println!( + "=== Filesystem loss items ({} item(s)) ===", + result.loss.len() + ); for item in &result.loss { println!(" [{}] {}: {}", item.severity, item.path, item.message); } @@ -305,6 +350,27 @@ mod imp { Ok(()) } + fn write_split_outputs( + policy_path: &Path, + out_dir: &Path, + options: &MxcMappingOptions, + result: &openshell_driver_mxc::SplitPolicyResult, + ) -> Result<()> { + write_outputs( + policy_path, + out_dir, + options, + &result.mxc_config, + &result.loss, + )?; + let trimmed_path = out_dir.join("trimmed-policy.yaml"); + let trimmed = openshell_policy::serialize_sandbox_policy(&result.proxy_policy) + .map_err(|e| anyhow!("serializing trimmed proxy policy: {e}"))?; + std::fs::write(&trimmed_path, trimmed) + .with_context(|| format!("writing {}", trimmed_path.display()))?; + Ok(()) + } + // ----------------------------------------------------------------------- // File discovery & slug helpers // ----------------------------------------------------------------------- diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 886bd0092a..5a42f7ce55 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -4,7 +4,7 @@ //! MXC compute backend: lifecycle logic, in-memory registry, exec-in-driver, //! and self-reported readiness. -use crate::mxc::{MxcFilesystem, MxcProcess, MxcProcessContainer, WxcExecInvoker}; +use crate::mxc::{MxcFilesystem, MxcNetwork, MxcProcess, MxcProcessContainer, WxcExecInvoker}; use crate::policy::{EmbeddedPolicyMapper, MapCtx, PolicyMapper}; use futures::Stream; use openshell_core::proto::SandboxPolicy; @@ -15,6 +15,7 @@ use openshell_core::proto::compute::v1::{ }; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::net::SocketAddr; use std::pin::Pin; use std::sync::Arc; use tokio::process::Child; @@ -73,6 +74,14 @@ pub struct MxcComputeConfig { /// Host directory mapped into the sandbox as a read-write grant. /// Appears in the shared host folder for the positive-proof artifact. pub share_dir: String, + /// Enable Pattern-C governed egress. When true, MXC receives filesystem + /// grants plus a `network.proxy` redirect and the host CONNECT proxy + /// receives a trimmed network-only policy. + pub egress_proxy: bool, + /// Loopback `IP:PORT` used for MXC `network.proxy` while governed egress is + /// enabled. Per-sandbox allocation is a follow-up; this is one configured + /// address for the initial integration. + pub egress_proxy_addr: String, /// Enable `--debug` flag on `wxc-exec` invocations. pub debug: bool, } @@ -88,6 +97,8 @@ impl Default for MxcComputeConfig { agent_command: Vec::new(), agent_cwd: String::new(), share_dir: String::new(), + egress_proxy: false, + egress_proxy_addr: String::new(), debug: false, } } @@ -108,6 +119,8 @@ struct SandboxEntry { iso_sandbox_id: Option, phase_state: PhaseState, exec_child: Option, + trimmed_policy: Option, + proxy_addr: Option, } impl std::fmt::Debug for SandboxEntry { @@ -116,6 +129,7 @@ impl std::fmt::Debug for SandboxEntry { .field("sandbox_id", &self.sandbox.id) .field("iso_sandbox_id", &self.iso_sandbox_id) .field("phase_state", &self.phase_state) + .field("proxy_addr", &self.proxy_addr) .finish_non_exhaustive() } } @@ -188,6 +202,28 @@ impl std::fmt::Debug for MxcComputeBackend { } } +fn configured_egress_addr(config: &MxcComputeConfig) -> Result, tonic::Status> { + if !config.egress_proxy { + return Ok(None); + } + if config.backend == MxcBackend::IsolationSession { + return Err(tonic::Status::invalid_argument( + "mxc governed egress requires process_container; network.proxy is not supported on isolation_session until MXC M1 lands", + )); + } + let raw = config.egress_proxy_addr.trim(); + if raw.is_empty() { + return Err(tonic::Status::invalid_argument( + "mxc egress_proxy_addr is required when egress_proxy is enabled", + )); + } + raw.parse::().map(Some).map_err(|e| { + tonic::Status::invalid_argument(format!( + "mxc egress_proxy_addr must be an IP:PORT socket address: {e}" + )) + }) +} + impl MxcComputeBackend { pub fn new(config: MxcComputeConfig) -> Self { let invoker = WxcExecInvoker::new(&config.wxc_exec_path, config.debug); @@ -248,6 +284,7 @@ impl MxcComputeBackend { "mxc driver: agent_command is required in [openshell.drivers.mxc]", )); } + configured_egress_addr(&self.config)?; Ok(()) } @@ -317,6 +354,8 @@ impl MxcComputeBackend { iso_sandbox_id: None, phase_state: PhaseState::Starting, exec_child: None, + trimmed_policy: None, + proxy_addr: None, }, ); } @@ -469,6 +508,13 @@ async fn run_lifecycle( ) { let sandbox_id = sandbox.id.clone(); let sandbox_name = sandbox.name.clone(); + let egress_addr = match configured_egress_addr(&config) { + Ok(addr) => addr, + Err(e) => { + set_failed(®istry, &watch_tx, &sandbox, &sandbox_id, &e.to_string()).await; + return; + } + }; // 1. Map policy → MXC filesystem config (A1: policy is now threaded in). let map_ctx = MapCtx { @@ -478,6 +524,7 @@ async fn run_lifecycle( } else { Some(config.share_dir.clone()) }, + egress: egress_addr, }; let mapped = match policy_mapper.map(policy.as_ref(), &map_ctx) { Ok(m) => m, @@ -486,6 +533,22 @@ async fn run_lifecycle( return; } }; + let trimmed_policy = mapped.trimmed_policy.clone(); + let proxy_addr = mapped.proxy_addr; + if let Some(addr) = proxy_addr { + { + let mut reg = registry.lock().await; + if let Some(entry) = reg.get_mut(&sandbox_id) { + entry.trimmed_policy = trimmed_policy; + entry.proxy_addr = Some(addr); + } + } + let _ = watch_tx.send(platform_event( + sandbox_id.clone(), + "EgressRedirect", + format!("MXC egress redirected to OpenShell host CONNECT proxy at {addr}"), + )); + } // 2. Build filesystem grants + the agent process (shared across backends). let filesystem = MxcFilesystem { @@ -507,6 +570,10 @@ async fn run_lifecycle( env: Vec::new(), timeout: 0, }; + let network = proxy_addr.map(|addr| MxcNetwork { + default_policy: "block".into(), + proxy: Some(addr), + }); // 3. Launch the agent. The backends differ fundamentally: // - isolation_session: persistent (provision -> start -> exec). @@ -514,7 +581,7 @@ async fn run_lifecycle( let child = match config.backend { MxcBackend::IsolationSession => { let iso_sandbox_id = match invoker - .provision(&config.default_configuration_id, filesystem) + .provision(&config.default_configuration_id, filesystem, network) .await { Ok(id) => id, @@ -551,7 +618,7 @@ async fn run_lifecycle( capabilities: config.pc_capabilities.clone(), }; match invoker - .run_oneshot(&sandbox_id, filesystem, pc, process) + .run_oneshot(&sandbox_id, filesystem, pc, process, network) .await { Ok(c) => c, @@ -797,6 +864,29 @@ mod lifecycle_tests { } } + #[test] + fn mxc_config_defaults_leave_egress_disabled() { + let config = MxcComputeConfig::default(); + assert!(!config.egress_proxy); + assert!(config.egress_proxy_addr.is_empty()); + } + + #[test] + fn egress_on_isolation_session_is_rejected() { + let mut config = demo_config( + "C:/work/demo", + vec!["cmd".into(), "/c".into(), "exit 0".into()], + ); + config.egress_proxy = true; + config.egress_proxy_addr = "127.0.0.1:18080".into(); + let backend = MxcComputeBackend::new_mocked(config); + let err = backend + .validate_sandbox_create(&driver_sandbox("sb-egress-iso")) + .unwrap_err(); + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert!(err.message().contains("MXC M1")); + } + #[tokio::test] async fn positive_in_policy_write_reaches_ready_and_materializes_file() { let tmp = tempfile::tempdir().unwrap(); @@ -882,7 +972,15 @@ mod lifecycle_tests { ready_condition(s).is_some_and(|c| c.status == "True" && c.reason == "AgentRunning") }) .await; - assert!(ready.is_some(), "processContainer sandbox should self-report Ready=True"); + assert!( + ready.is_some(), + "processContainer sandbox should self-report Ready=True" + ); + let recorded = crate::mxc::mock_recorded_config("sb-pc").expect("mock recorded config"); + assert!( + recorded.get("network").is_none(), + "coarse path must not emit an MXC network block" + ); let host_path = std::path::Path::new(tmp.path()).join("hello.txt"); let mut found = false; @@ -893,7 +991,109 @@ mod lifecycle_tests { } tokio::time::sleep(Duration::from_millis(100)).await; } - assert!(found, "in-policy write should materialize under processContainer"); + assert!( + found, + "in-policy write should materialize under processContainer" + ); + } + + #[tokio::test] + async fn split_path_provisions_with_proxy_redirect() { + use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule}; + + let tmp = tempfile::tempdir().unwrap(); + let share = tmp.path().to_string_lossy().replace('\\', "/"); + let hello = format!("{share}/hello.txt"); + let cmd = vec![ + "powershell".into(), + "-NoProfile".into(), + "-Command".into(), + format!("Set-Content -LiteralPath {hello} -Value hi"), + ]; + let mut config = demo_config(&share, cmd); + config.backend = MxcBackend::ProcessContainer; + config.egress_proxy = true; + config.egress_proxy_addr = "127.0.0.1:18080".into(); + let backend = MxcComputeBackend::new_mocked(config); + let mut stream = backend.watch_sandboxes().await; + + let mut policy = fs_policy(&[&share]); + policy.network_policies.insert( + "api".into(), + NetworkPolicyRule { + name: "api".into(), + endpoints: vec![NetworkEndpoint { + host: "example.com".into(), + ports: vec![443], + protocol: "rest".into(), + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".into(), + ..Default::default() + }], + }, + ); + backend + .policy_sink() + .lock() + .await + .insert("sb-egress".into(), policy.clone()); + + backend + .create_sandbox(&driver_sandbox("sb-egress")) + .await + .expect("create accepted"); + + let ready = wait_for(&backend, "sb-egress", |s| { + ready_condition(s).is_some_and(|c| c.status == "True" && c.reason == "AgentRunning") + }) + .await; + assert!( + ready.is_some(), + "egress split sandbox should reach Ready=True" + ); + + let recorded = crate::mxc::mock_recorded_config("sb-egress").expect("mock recorded config"); + assert_eq!(recorded["network"]["defaultPolicy"], "block"); + assert!( + recorded["network"]["allowedHosts"] + .as_array() + .unwrap() + .is_empty() + ); + assert_eq!(recorded["network"]["proxy"]["host"], "127.0.0.1"); + assert_eq!(recorded["network"]["proxy"]["port"], 18080); + + let reg = backend.registry.lock().await; + let entry = reg.get("sb-egress").expect("registry entry"); + assert_eq!(entry.proxy_addr, Some("127.0.0.1:18080".parse().unwrap())); + assert_eq!( + entry.trimmed_policy.as_ref().unwrap().network_policies, + policy.network_policies + ); + drop(reg); + + let mut saw_redirect = false; + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + while tokio::time::Instant::now() < deadline { + match tokio::time::timeout(Duration::from_millis(500), stream.next()).await { + Ok(Some(Ok(ev))) => { + if let Some(watch_sandboxes_event::Payload::PlatformEvent(pe)) = ev.payload + && pe + .event + .as_ref() + .is_some_and(|e| e.reason == "EgressRedirect") + { + saw_redirect = true; + break; + } + } + Ok(_) => break, + Err(_) => continue, + } + } + assert!(saw_redirect, "expected EgressRedirect platform event"); } #[tokio::test] diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs index 8aca35d986..033e1d3365 100644 --- a/crates/openshell-driver-mxc/src/mxc.rs +++ b/crates/openshell-driver-mxc/src/mxc.rs @@ -10,6 +10,7 @@ use base64::Engine as _; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::net::SocketAddr; use std::path::PathBuf; use std::sync::{Mutex, OnceLock}; use thiserror::Error; @@ -51,13 +52,33 @@ fn mock_grants() -> &'static Mutex>> { // ── Request types ───────────────────────────────────────────────────────────── -/// Filesystem shares for the sandbox (MXC provision-time only). -#[derive(Debug, Default, Serialize)] +/// Filesystem shares for the sandbox. +/// +/// `isolation_session` honors `readwrite`/`readonly` (grant-only — it has no +/// deny primitive). `processContainer` additionally honors `denied_paths` +/// because the AppContainer backend can stamp deny ACEs; it is also genuinely +/// default-deny, so anything not granted is already inaccessible. +#[derive(Debug, Default)] pub struct MxcFilesystem { - #[serde(rename = "readwritePaths", skip_serializing_if = "Vec::is_empty")] pub readwrite_paths: Vec, - #[serde(rename = "readonlyPaths", skip_serializing_if = "Vec::is_empty")] pub readonly_paths: Vec, + pub denied_paths: Vec, +} + +/// Network redirect fragment emitted when governed egress is enabled. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MxcNetwork { + pub default_policy: String, + pub proxy: Option, +} + +/// `processContainer`-specific knobs (one-shot AppContainer backend). +#[derive(Debug, Default, Clone)] +pub struct MxcProcessContainer { + /// Request a Less-Privileged AppContainer (stricter default-deny). + pub least_privilege: bool, + /// AppContainer capabilities to grant (e.g. `internetClient`). + pub capabilities: Vec, } /// Process config for the exec phase. @@ -72,6 +93,107 @@ pub struct MxcProcess { pub timeout: u64, } +fn network_json(network: &MxcNetwork) -> serde_json::Value { + let mut value = serde_json::json!({ + "defaultPolicy": network.default_policy.as_str(), + "allowedHosts": [], + "blockedHosts": [], + }); + if let Some(proxy) = network.proxy { + value["proxy"] = serde_json::json!({ + "host": proxy.ip().to_string(), + "port": proxy.port(), + }); + } + value +} + +fn provision_config_json( + configuration_id: &str, + filesystem: &MxcFilesystem, + network: Option<&MxcNetwork>, +) -> serde_json::Value { + let mut config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "phase": "provision", + "containment": "isolation_session", + "filesystem": { + "readwritePaths": &filesystem.readwrite_paths, + "readonlyPaths": &filesystem.readonly_paths, + }, + "experimental": { + "isolation_session": { + "configurationId": configuration_id, + "provision": {} + } + } + }); + if let Some(network) = network { + config["network"] = network_json(network); + } + config +} + +fn oneshot_config_json( + container_id: &str, + filesystem: &MxcFilesystem, + pc: &MxcProcessContainer, + process: &MxcProcess, + network: Option<&MxcNetwork>, +) -> serde_json::Value { + let mut filesystem_json = serde_json::Map::new(); + if !filesystem.readwrite_paths.is_empty() { + filesystem_json.insert( + "readwritePaths".into(), + filesystem.readwrite_paths.clone().into(), + ); + } + if !filesystem.readonly_paths.is_empty() { + filesystem_json.insert( + "readonlyPaths".into(), + filesystem.readonly_paths.clone().into(), + ); + } + if !filesystem.denied_paths.is_empty() { + filesystem_json.insert("deniedPaths".into(), filesystem.denied_paths.clone().into()); + } + + let mut pc_json = serde_json::Map::new(); + pc_json.insert("leastPrivilege".into(), pc.least_privilege.into()); + if !pc.capabilities.is_empty() { + pc_json.insert("capabilities".into(), pc.capabilities.clone().into()); + } + + let mut config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "containerId": container_id, + "containment": "processcontainer", + "process": { + "commandLine": process.command_line.as_str(), + "cwd": process.cwd.as_str(), + "env": &process.env, + "timeout": process.timeout, + }, + "processContainer": serde_json::Value::Object(pc_json), + "filesystem": serde_json::Value::Object(filesystem_json), + }); + if let Some(network) = network { + config["network"] = network_json(network); + } + config +} + +#[cfg(test)] +fn mock_configs() -> &'static Mutex> { + static CONFIGS: OnceLock>> = OnceLock::new(); + CONFIGS.get_or_init(|| Mutex::new(HashMap::new())) +} + +#[cfg(test)] +pub(crate) fn mock_recorded_config(id: &str) -> Option { + mock_configs().lock().unwrap().get(id).cloned() +} + // ── Response envelope ───────────────────────────────────────────────────────── #[derive(Debug, Deserialize)] @@ -249,6 +371,7 @@ impl WxcExecInvoker { &self, configuration_id: &str, filesystem: MxcFilesystem, + network: Option, ) -> Result { if self.mock { // Mock provision: mint a synthetic `iso:` id and record the granted @@ -260,24 +383,15 @@ impl WxcExecInvoker { .map(|p| mock_normalize(p)) .collect(); mock_grants().lock().unwrap().insert(id.clone(), grants); + #[cfg(test)] + { + let config = provision_config_json(configuration_id, &filesystem, network.as_ref()); + mock_configs().lock().unwrap().insert(id.clone(), config); + } debug!(sandbox_id = %id, "mock wxc-exec provision"); return Ok(id); } - let config = serde_json::json!({ - "version": MXC_SCHEMA_VERSION, - "phase": "provision", - "containment": "isolation_session", - "filesystem": { - "readwritePaths": filesystem.readwrite_paths, - "readonlyPaths": filesystem.readonly_paths, - }, - "experimental": { - "isolation_session": { - "configurationId": configuration_id, - "provision": {} - } - } - }); + let config = provision_config_json(configuration_id, &filesystem, network.as_ref()); let json = serde_json::to_string(&config)?; let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); @@ -391,10 +505,6 @@ impl WxcExecInvoker { /// /// The agent's write target is considered **in-policy** iff the command line /// references one of the granted read-write paths recorded at mock provision. - /// In-policy → run the real agent command (so the positive-proof artifact, - /// e.g. `hello.txt`, actually appears on the host shared folder). Out-of-policy - /// → refuse with an access-denied message on stderr and a non-zero exit, - /// mirroring how the AppContainer denies the write on the demo box. fn mock_spawn_exec( &self, iso_sandbox_id: &str, @@ -406,6 +516,20 @@ impl WxcExecInvoker { .get(iso_sandbox_id) .cloned() .unwrap_or_default(); + Self::mock_spawn_with_grants(process, &grants) + } + + /// Shared mock enforcement used by both the `isolation_session` exec phase + /// and the one-shot `processContainer` path. + /// + /// In-policy → run the real agent command (so the positive-proof artifact, + /// e.g. `hello.txt`, actually appears on the host shared folder). Out-of-policy + /// → refuse with an access-denied message on stderr and a non-zero exit, + /// mirroring how the `AppContainer` denies the write on the demo box. + fn mock_spawn_with_grants( + process: &MxcProcess, + grants: &[String], + ) -> Result { let cmd_norm = mock_normalize(&process.command_line); let in_policy = grants.iter().any(|g| !g.is_empty() && cmd_norm.contains(g)); @@ -414,11 +538,10 @@ impl WxcExecInvoker { .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); if in_policy { - debug!(sandbox_id = %iso_sandbox_id, command = %process.command_line, "mock exec: in-policy, running agent"); + debug!(command = %process.command_line, "mock exec: in-policy, running agent"); cmd.arg("/c").arg(&process.command_line); } else { - debug!(sandbox_id = %iso_sandbox_id, command = %process.command_line, "mock exec: OUT-OF-POLICY, denying"); - // Emit an access-denied message to stderr and exit non-zero. + debug!(command = %process.command_line, "mock exec: OUT-OF-POLICY, denying"); cmd.arg("/c").arg( "echo Access is denied. (out-of-policy write blocked by AppContainer) 1>&2& exit 1", ); @@ -427,6 +550,57 @@ impl WxcExecInvoker { Ok(child) } + /// Build a **one-shot** `processContainer` config (no `phase`) and spawn it. + /// + /// Unlike the `isolation_session` lifecycle (provision → start → exec → + /// stop → deprovision), `processContainer` is a single ephemeral + /// AppContainer: one `wxc-exec` invocation creates the container, runs the + /// one process, and tears down when it exits. The AppContainer is genuinely + /// default-deny, so a write to any ungranted path is denied by the OS. + /// + /// **Stdout is raw agent output; the exit code is the agent's own exit code.** + pub async fn run_oneshot( + &self, + container_id: &str, + filesystem: MxcFilesystem, + pc: MxcProcessContainer, + process: MxcProcess, + network: Option, + ) -> Result { + let config = + oneshot_config_json(container_id, &filesystem, &pc, &process, network.as_ref()); + if self.mock { + let grants: Vec = filesystem + .readwrite_paths + .iter() + .map(|p| mock_normalize(p)) + .collect(); + #[cfg(test)] + mock_configs() + .lock() + .unwrap() + .insert(container_id.to_owned(), config); + return Self::mock_spawn_with_grants(&process, &grants); + } + + let json = serde_json::to_string(&config)?; + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + + let mut cmd = Command::new(&self.exec_path); + cmd.arg("--config-base64") + .arg(&b64) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + if self.debug { + cmd.arg("--debug"); + } + + debug!(container_id = %container_id, command = %process.command_line, "wxc-exec one-shot processContainer spawn"); + let child = cmd.spawn()?; + Ok(child) + } + /// Run the stop phase. pub async fn stop(&self, iso_sandbox_id: &str) -> Result<(), InvokerError> { let config = serde_json::json!({ @@ -523,6 +697,87 @@ mod tests { assert_eq!(config["filesystem"]["readwritePaths"][0], "C:\\work\\demo"); } + #[test] + fn oneshot_processcontainer_config_json_shape() { + // Mirror the JSON `run_oneshot` builds for the one-shot processContainer + // path: no `phase` (routes to one-shot), `containment: processcontainer`, + // a `process` block, the `processContainer` knobs, and filesystem grants + // incl. deniedPaths. + let config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "containerId": "sb-1", + "containment": "processcontainer", + "process": { + "commandLine": "C:\\work\\demo\\agent.exe", + "cwd": "C:\\work\\demo", + "env": Vec::::new(), + "timeout": 0, + }, + "processContainer": { "leastPrivilege": true }, + "filesystem": { + "readwritePaths": ["C:\\work\\demo"], + "deniedPaths": ["C:\\secret"], + }, + }); + assert_eq!(config["containment"], "processcontainer"); + assert!( + config.get("phase").is_none(), + "one-shot config must omit phase" + ); + assert_eq!(config["processContainer"]["leastPrivilege"], true); + assert_eq!(config["filesystem"]["readwritePaths"][0], "C:\\work\\demo"); + assert_eq!(config["filesystem"]["deniedPaths"][0], "C:\\secret"); + } + + #[test] + fn provision_config_json_includes_network_proxy_when_supplied() { + let filesystem = MxcFilesystem { + readwrite_paths: vec!["C:\\work\\demo".into()], + readonly_paths: Vec::new(), + denied_paths: Vec::new(), + }; + let network = MxcNetwork { + default_policy: "block".into(), + proxy: Some("127.0.0.1:18080".parse().unwrap()), + }; + let config = provision_config_json(DEFAULT_CONFIGURATION_ID, &filesystem, Some(&network)); + + assert_eq!(config["network"]["defaultPolicy"], "block"); + assert!( + config["network"]["allowedHosts"] + .as_array() + .unwrap() + .is_empty() + ); + assert!( + config["network"]["blockedHosts"] + .as_array() + .unwrap() + .is_empty() + ); + assert_eq!(config["network"]["proxy"]["host"], "127.0.0.1"); + assert_eq!(config["network"]["proxy"]["port"], 18080); + } + + #[test] + fn oneshot_config_json_omits_network_without_proxy() { + let filesystem = MxcFilesystem { + readwrite_paths: vec!["C:\\work\\demo".into()], + readonly_paths: Vec::new(), + denied_paths: Vec::new(), + }; + let pc = MxcProcessContainer::default(); + let process = MxcProcess { + command_line: "cmd /c exit 0".into(), + cwd: "C:\\work\\demo".into(), + env: Vec::new(), + timeout: 0, + }; + let config = oneshot_config_json("sb-1", &filesystem, &pc, &process, None); + + assert!(config.get("network").is_none()); + } + #[test] fn invoker_error_maps_backend_unavailable_to_unavailable() { let err = InvokerError::Mxc { diff --git a/crates/openshell-driver-mxc/src/policy.rs b/crates/openshell-driver-mxc/src/policy.rs index bcf48ca6be..068873ac8b 100644 --- a/crates/openshell-driver-mxc/src/policy.rs +++ b/crates/openshell-driver-mxc/src/policy.rs @@ -19,19 +19,27 @@ //! **Rule: never silently drop policy.** Unmappable rules surface as //! `MapError::Unsupported` and are rejected in `ValidateSandboxCreate`. +use std::net::SocketAddr; + use openshell_core::proto::SandboxPolicy; use thiserror::Error; /// The MXC config fragment derived from a `SandboxPolicy`. /// -/// Carries filesystem share lists for the MXC provision phase. -/// Future fields: `network_proxy` (Stage 2 egress skill). +/// Carries filesystem share lists for the MXC provision phase, plus the +/// Pattern-C governed-egress handoff when enabled. #[derive(Debug, Default, Clone)] pub struct MappedConfig { /// Paths granted read-write access inside the sandbox. pub readwrite_paths: Vec, /// Paths granted read-only access inside the sandbox. pub readonly_paths: Vec, + /// Network-only policy for the host CONNECT proxy. `None` on the coarse + /// filesystem-only path. + pub trimmed_policy: Option, + /// Loopback address MXC redirects sandbox egress to. `None` when governed + /// egress is disabled. + pub proxy_addr: Option, } /// Context passed to the mapper alongside the policy. @@ -43,6 +51,9 @@ pub struct MapCtx { /// Host share directory for the demo positive proof. Always granted /// read-write so `hello.txt` is visible on the host. pub share_dir: Option, + /// Pattern-C governed-egress redirect address. When set, the embedded + /// mapper uses `split_policy`; otherwise it uses the coarse MXC map. + pub egress: Option, } /// A policy rule that the active mapper cannot enforce. @@ -112,21 +123,43 @@ impl PolicyMapper for EmbeddedPolicyMapper { ) })?; - // Map directly off the typed proto. The MXC driver runs an isolation - // session, so use that containment: its network branch yields an - // `error` loss for any host allowlist, which is what rejects network - // policy below. - let opts = crate::policy_map::MxcMappingOptions { - containment: "isolation_session".to_owned(), - container_id: ctx.sandbox_id.clone(), - ..Default::default() + let (config, loss, trimmed_policy, proxy_addr) = if let Some(addr) = ctx.egress { + // Pattern C: MXC handles filesystem + a proxy redirect, while the + // host CONNECT proxy receives the network-only trimmed policy. + let opts = crate::policy_map::MxcMappingOptions { + containment: "processcontainer".to_owned(), + container_id: ctx.sandbox_id.clone(), + proxy_redirect: Some(addr), + ..Default::default() + }; + let result = crate::policy_map::split_policy(policy, &opts).ok_or_else(|| { + MapError::Internal( + "egress proxy was enabled but no proxy address was supplied".into(), + ) + })?; + ( + result.mxc_config, + result.loss, + Some(result.proxy_policy), + Some(addr), + ) + } else { + // Map directly off the typed proto. The default MXC driver path runs + // an isolation session, so use that containment: its network branch + // yields an `error` loss for any host allowlist, which rejects + // network policy below. + let opts = crate::policy_map::MxcMappingOptions { + containment: "isolation_session".to_owned(), + container_id: ctx.sandbox_id.clone(), + ..Default::default() + }; + let result = crate::policy_map::map_to_mxc(policy, &opts); + (result.config, result.loss, None, None) }; - let result = crate::policy_map::map_to_mxc(policy, &opts); // Reject the create on any error-severity loss. Warnings/info (e.g. the // filesystem default-deny note) are advisory and do not block. - let errors: Vec = result - .loss + let errors: Vec = loss .iter() .filter(|i| i.severity == "error") .map(|i| LossItem { @@ -140,11 +173,11 @@ impl PolicyMapper for EmbeddedPolicyMapper { // The embedded mapper copies paths verbatim; normalize them (and the // demo share dir) to Windows backslash form here, in one place. - let mut readwrite: Vec = extract_paths(&result.config, "readwritePaths") + let mut readwrite: Vec = extract_paths(&config, "readwritePaths") .iter() .map(|p| normalize_path(p)) .collect(); - let readonly: Vec = extract_paths(&result.config, "readonlyPaths") + let readonly: Vec = extract_paths(&config, "readonlyPaths") .iter() .map(|p| normalize_path(p)) .collect(); @@ -162,6 +195,8 @@ impl PolicyMapper for EmbeddedPolicyMapper { Ok(MappedConfig { readwrite_paths: readwrite, readonly_paths: readonly, + trimmed_policy, + proxy_addr, }) } } @@ -198,6 +233,7 @@ mod tests { MapCtx { sandbox_id: "sb-test".into(), share_dir: share_dir.map(str::to_string), + egress: None, } } @@ -264,4 +300,43 @@ mod tests { let err = mapper.map(Some(&policy), &ctx).unwrap_err(); assert!(matches!(err, MapError::Unsupported(_))); } + + #[test] + fn embedded_split_normalizes_paths_and_returns_proxy_handoff() { + use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule}; + let mapper = EmbeddedPolicyMapper; + let mut policy = fs_policy(&["C:/work/demo"], &["C:/tools"]); + policy.version = 1; + policy.network_policies.insert( + "api".to_string(), + NetworkPolicyRule { + name: "api".into(), + endpoints: vec![NetworkEndpoint { + host: "example.com".into(), + ports: vec![443], + protocol: "rest".into(), + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".into(), + ..Default::default() + }], + }, + ); + let proxy_addr = "127.0.0.1:18080".parse().unwrap(); + let ctx = MapCtx { + sandbox_id: "sb-egress".into(), + share_dir: Some("C:/work/demo".into()), + egress: Some(proxy_addr), + }; + + let config = mapper.map(Some(&policy), &ctx).unwrap(); + assert_eq!(config.readwrite_paths, vec!["C:\\work\\demo"]); + assert_eq!(config.readonly_paths, vec!["C:\\tools"]); + assert_eq!(config.proxy_addr, Some(proxy_addr)); + let trimmed = config.trimmed_policy.expect("trimmed policy"); + assert_eq!(trimmed.version, policy.version); + assert_eq!(trimmed.network_policies, policy.network_policies); + assert!(trimmed.filesystem.is_none()); + } } diff --git a/crates/openshell-driver-mxc/src/policy_map/map.rs b/crates/openshell-driver-mxc/src/policy_map/map.rs index cf690cc3d8..49089b30b2 100644 --- a/crates/openshell-driver-mxc/src/policy_map/map.rs +++ b/crates/openshell-driver-mxc/src/policy_map/map.rs @@ -9,6 +9,8 @@ //! item. The top-level `network_policies` map is iterated in sorted key order //! so the output is deterministic (the proto map is unordered). +use std::net::SocketAddr; + use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule, SandboxPolicy}; use serde_json::{Value, json}; @@ -19,8 +21,7 @@ use super::config::{ use super::loss::{LossItem, add_loss}; /// Options controlling the generated MXC config. Fields not relevant to the -/// coarse map (e.g. `proxy_localhost_port`) are reserved for the lossless -/// split. +/// coarse map (e.g. `proxy_redirect`) are reserved for the lossless split. #[derive(Clone, Debug)] pub struct MxcMappingOptions { /// MXC schema version written into `version`. @@ -39,9 +40,9 @@ pub struct MxcMappingOptions { pub timeout_ms: u64, /// Emit `OpenShell` wildcard hosts into `allowedHosts` despite lossiness. pub allow_wildcards: bool, - /// Governed-egress redirect port (used by the lossless split, not the + /// Governed-egress redirect address (used by the lossless split, not the /// coarse map). - pub proxy_localhost_port: Option, + pub proxy_redirect: Option, } impl Default for MxcMappingOptions { @@ -55,7 +56,7 @@ impl Default for MxcMappingOptions { env: Vec::new(), timeout_ms: 0, allow_wildcards: false, - proxy_localhost_port: None, + proxy_redirect: None, } } } @@ -98,19 +99,20 @@ pub fn map_to_mxc(policy: &SandboxPolicy, opts: &MxcMappingOptions) -> MxcMappin /// `OpenShell` CONNECT proxy. /// /// The returned [`SplitPolicyResult::mxc_config`] sets `network.proxy` to -/// `127.0.0.1:{proxy_localhost_port}` and leaves `allowedHosts` empty — direct +/// `opts.proxy_redirect` and leaves `allowedHosts` empty — direct /// egress is blocked at the MXC layer and all outbound connections flow through /// the proxy. [`SplitPolicyResult::proxy_policy`] carries the original /// `network_policies` verbatim; no binary-scope, port, protocol, or wildcard /// loss items are generated for the network side. /// -/// Returns `None` if `opts.proxy_localhost_port` is not set. Use [`map_to_mxc`] +/// Returns `None` if `opts.proxy_redirect` is not set. Use [`map_to_mxc`] /// for the standalone coarse path when no proxy is in the loop. pub fn split_policy(policy: &SandboxPolicy, opts: &MxcMappingOptions) -> Option { - let port = opts.proxy_localhost_port?; + let proxy_addr = opts.proxy_redirect?; let mut loss = Vec::new(); - let mxc_config = build_split_mxc_config(policy, opts, port, &mut loss); + let mxc_config = build_split_mxc_config(policy, opts, proxy_addr, &mut loss); let proxy_policy = SandboxPolicy { + version: policy.version, network_policies: policy.network_policies.clone(), ..Default::default() }; @@ -124,7 +126,7 @@ pub fn split_policy(policy: &SandboxPolicy, opts: &MxcMappingOptions) -> Option< fn build_split_mxc_config( policy: &SandboxPolicy, opts: &MxcMappingOptions, - proxy_port: u16, + proxy_addr: SocketAddr, items: &mut Vec, ) -> Value { let mut process = json!({ @@ -140,17 +142,47 @@ fn build_split_mxc_config( let filesystem = map_filesystem(policy, opts, items); + let proxy_supported = matches!(opts.containment.as_str(), "processcontainer" | "process"); + if !proxy_supported { + add_loss( + items, + "containment", + "error", + &format!( + "`network.proxy` is not supported on `{}`; governed egress requires processcontainer until MXC M1 lands.", + opts.containment + ), + "governed egress proxy redirect", + "The generated MXC config omits network.proxy for this backend.", + ); + } + if !policy.network_policies.is_empty() { + add_loss( + items, + "network_policies", + "info", + &format!( + "{} network rule(s) delegated to the OpenShell host CONNECT proxy.", + policy.network_policies.len() + ), + "governed egress", + "The host proxy receives the trimmed policy and enforces network rules.", + ); + } + // Direct egress is blocked; all outbound flows through the OpenShell proxy. // allowedHosts is intentionally empty — the proxy enforces the full policy. - let network = json!({ + let mut network = json!({ "defaultPolicy": "block", "allowedHosts": [], "blockedHosts": [], - "proxy": { - "host": "127.0.0.1", - "port": proxy_port, - }, }); + if proxy_supported { + network["proxy"] = json!({ + "host": proxy_addr.ip().to_string(), + "port": proxy_addr.port(), + }); + } let mut config = json!({ "version": opts.mxc_version, diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs b/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs index 344d8b4ad6..30de7157ea 100644 --- a/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs +++ b/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs @@ -19,7 +19,7 @@ use std::path::{Path, PathBuf}; use openshell_driver_mxc::{MxcMappingOptions, map_to_mxc, split_policy}; -use openshell_policy::parse_sandbox_policy; +use openshell_policy::{parse_sandbox_policy, serialize_sandbox_policy, validate_sandbox_policy}; use serde_json::Value; fn examples_root() -> PathBuf { @@ -56,6 +56,10 @@ fn str_list(value: &Value) -> Vec { .unwrap_or_default() } +fn proxy_addr() -> std::net::SocketAddr { + "127.0.0.1:18080".parse().unwrap() +} + #[test] fn all_example_policies_map_with_invariants() { let root = examples_root(); @@ -137,6 +141,88 @@ fn all_example_policies_map_with_invariants() { } } +#[test] +fn all_example_policies_split_with_lossless_invariants() { + let root = examples_root(); + let mut policies = Vec::new(); + discover(&root, &mut policies); + policies.sort(); + assert!( + !policies.is_empty(), + "no example policies found under {}", + root.display() + ); + + for path in &policies { + let yaml = std::fs::read_to_string(path).expect("read policy"); + let policy = parse_sandbox_policy(&yaml) + .unwrap_or_else(|e| panic!("parse {} failed: {e}", path.display())); + let opts = MxcMappingOptions { + containment: "processcontainer".to_owned(), + proxy_redirect: Some(proxy_addr()), + ..Default::default() + }; + let result = split_policy(&policy, &opts) + .unwrap_or_else(|| panic!("split returned None for {}", path.display())); + let cfg = &result.mxc_config; + + assert_eq!( + result.proxy_policy.network_policies, + policy.network_policies, + "proxy_policy must carry network rules verbatim for {}", + path.display() + ); + assert_eq!( + result.proxy_policy.version, + policy.version, + "proxy_policy must preserve version for {}", + path.display() + ); + validate_sandbox_policy(&result.proxy_policy).unwrap_or_else(|e| { + panic!("trimmed policy must validate for {}: {e:?}", path.display()) + }); + let serialized = serialize_sandbox_policy(&result.proxy_policy) + .unwrap_or_else(|e| panic!("serialize trimmed policy for {}: {e}", path.display())); + let round_trip = parse_sandbox_policy(&serialized) + .unwrap_or_else(|e| panic!("parse trimmed round-trip for {}: {e}", path.display())); + assert_eq!( + round_trip, + result.proxy_policy, + "trimmed policy must round-trip for {}", + path.display() + ); + + if let Some(fs) = &policy.filesystem { + assert_eq!( + str_list(&cfg["filesystem"]["readwritePaths"]), + fs.read_write, + "split readwrite mismatch for {}", + path.display() + ); + assert_eq!( + str_list(&cfg["filesystem"]["readonlyPaths"]), + fs.read_only, + "split readonly mismatch for {}", + path.display() + ); + } else { + assert!(str_list(&cfg["filesystem"]["readwritePaths"]).is_empty()); + assert!(str_list(&cfg["filesystem"]["readonlyPaths"]).is_empty()); + } + + assert_eq!(cfg["network"]["defaultPolicy"], "block"); + assert!(str_list(&cfg["network"]["allowedHosts"]).is_empty()); + assert_eq!(cfg["network"]["proxy"]["host"], "127.0.0.1"); + assert_eq!(cfg["network"]["proxy"]["port"], 18080); + assert!( + result.loss.iter().all(|i| i.severity != "error"), + "processcontainer split must not emit error losses for {}: {:?}", + path.display(), + result.loss + ); + } +} + #[test] fn quickstart_coarse_mapping() { let path = examples_root().join("sandbox-policy-quickstart/policy.yaml"); @@ -184,14 +270,15 @@ fn split_policy_routes_network_to_proxy() { let policy = parse_sandbox_policy(&yaml).expect("parse quickstart"); let opts = MxcMappingOptions { - proxy_localhost_port: Some(8080), + containment: "processcontainer".to_owned(), + proxy_redirect: Some("127.0.0.2:8080".parse().unwrap()), ..Default::default() }; - let result = split_policy(&policy, &opts).expect("split_policy returns Some when port is set"); + let result = split_policy(&policy, &opts).expect("split_policy returns Some when addr is set"); let cfg = &result.mxc_config; // Proxy redirect is emitted. - assert_eq!(cfg["network"]["proxy"]["host"], "127.0.0.1"); + assert_eq!(cfg["network"]["proxy"]["host"], "127.0.0.2"); assert_eq!(cfg["network"]["proxy"]["port"], 8080); // Direct egress is blocked; allowedHosts is empty (proxy enforces the list). @@ -209,10 +296,10 @@ fn split_policy_routes_network_to_proxy() { // Network policy is returned verbatim for the proxy. assert_eq!( - result.proxy_policy.network_policies.len(), - policy.network_policies.len(), - "proxy_policy must carry all network rules" + result.proxy_policy.network_policies, policy.network_policies, + "proxy_policy must carry all network rules verbatim" ); + assert_eq!(result.proxy_policy.version, policy.version); assert!( result.proxy_policy.filesystem.is_none(), "proxy_policy must not carry filesystem rules" @@ -222,21 +309,24 @@ fn split_policy_routes_network_to_proxy() { let net_losses: Vec<_> = result .loss .iter() - .filter(|i| i.path.starts_with("network_policies")) + .filter(|i| i.path.starts_with("network_policies") && i.severity != "info") .collect(); assert!( net_losses.is_empty(), - "split path must not generate network losses: {net_losses:?}" + "split path must not generate lossy network items: {net_losses:?}" ); + assert!(result.loss.iter().any(|i| { + i.path == "network_policies" && i.severity == "info" && i.message.contains("delegated") + })); } #[test] -fn split_policy_returns_none_without_port() { +fn split_policy_returns_none_without_proxy_addr() { let opts = MxcMappingOptions::default(); let policy = parse_sandbox_policy("").unwrap_or_default(); assert!( split_policy(&policy, &opts).is_none(), - "split_policy must return None when proxy_localhost_port is not set" + "split_policy must return None when proxy_redirect is not set" ); } @@ -246,12 +336,42 @@ fn split_policy_deterministic() { let yaml = std::fs::read_to_string(&path).expect("read quickstart"); let policy = parse_sandbox_policy(&yaml).expect("parse quickstart"); let opts = MxcMappingOptions { - proxy_localhost_port: Some(9999), + containment: "processcontainer".to_owned(), + proxy_redirect: Some("127.0.0.1:9999".parse().unwrap()), ..Default::default() }; let a = split_policy(&policy, &opts).unwrap(); let b = split_policy(&policy, &opts).unwrap(); - assert_eq!(a.mxc_config, b.mxc_config, "split_policy must be deterministic"); + assert_eq!( + a.mxc_config, b.mxc_config, + "split_policy must be deterministic" + ); +} + +#[test] +fn split_policy_rejects_proxy_redirect_on_isolation_session() { + let path = examples_root().join("sandbox-policy-quickstart/policy.yaml"); + let yaml = std::fs::read_to_string(&path).expect("read quickstart"); + let policy = parse_sandbox_policy(&yaml).expect("parse quickstart"); + let opts = MxcMappingOptions { + containment: "isolation_session".to_owned(), + proxy_redirect: Some(proxy_addr()), + ..Default::default() + }; + let result = split_policy(&policy, &opts).unwrap(); + let errors: Vec<_> = result + .loss + .iter() + .filter(|i| i.severity == "error") + .collect(); + assert_eq!( + errors.len(), + 1, + "expected one containment error: {errors:?}" + ); + assert_eq!(errors[0].path, "containment"); + assert!(errors[0].message.contains("MXC M1")); + assert!(result.mxc_config["network"].get("proxy").is_none()); } #[test] From 0737e99e8b97477b2a81d89318985ba9f2e42b17 Mon Sep 17 00:00:00 2001 From: Giedrius Burachas Date: Thu, 11 Jun 2026 18:04:10 -0700 Subject: [PATCH 10/19] fix(driver-mxc): emit MXC network.proxy as {localhost: port} Verified against the real wxc-exec 0.6.0-alpha via --dry-run: MXC accepts only the {localhost: N} proxy shape (the form the design doc specifies) and rejects {host, port} with a parse error. Schema 0.6.0-alpha can express only a loopback port, so non-127.0.0.1 redirect addresses are now rejected: split_policy emits an error loss (no proxy block) and the driver refuses egress_proxy_addr values off 127.0.0.1. Per-sandbox attribution must use per-sandbox ports until the schema widens. Signed-off-by: Giedrius Burachas (cherry picked from commit edde8d5434571fd5398409204fcf6862672c0793) Signed-off-by: Jamie King --- crates/openshell-driver-mxc/src/driver.rs | 52 ++++++++- crates/openshell-driver-mxc/src/mxc.rs | 38 +++++- .../src/policy_map/map.rs | 27 ++++- .../tests/policy_mapper_examples.rs | 110 +++++++++++++++++- 4 files changed, 207 insertions(+), 20 deletions(-) diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 5a42f7ce55..d4ae601f17 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -217,11 +217,24 @@ fn configured_egress_addr(config: &MxcComputeConfig) -> Result().map(Some).map_err(|e| { + let addr = raw.parse::().map_err(|e| { tonic::Status::invalid_argument(format!( "mxc egress_proxy_addr must be an IP:PORT socket address: {e}" )) - }) + })?; + // MXC 0.6.0-alpha expresses the redirect as {"proxy": {"localhost": N}} + // — it has no way to encode a non-loopback host. Reject early so the + // operator gets a clear message rather than a silent policy gap. + // Use 127.0.0.1:PORT. Future schema versions may lift this restriction. + if addr.ip() != std::net::IpAddr::from([127, 0, 0, 1]) { + return Err(tonic::Status::invalid_argument(format!( + "mxc egress_proxy_addr must be 127.0.0.1:PORT — MXC 0.6.0-alpha \ + expresses the redirect as {{\"localhost\": N}} and cannot encode \ + non-loopback addresses (got {})", + addr.ip() + ))); + } + Ok(Some(addr)) } impl MxcComputeBackend { @@ -871,6 +884,29 @@ mod lifecycle_tests { assert!(config.egress_proxy_addr.is_empty()); } + #[test] + fn egress_non_loopback_addr_is_rejected() { + // MXC 0.6.0-alpha can only express {"proxy": {"localhost": N}}, so + // non-127.0.0.1 redirect addresses must be rejected at validate time. + let mut config = demo_config( + "C:/work/demo", + vec!["cmd".into(), "/c".into(), "exit 0".into()], + ); + config.backend = MxcBackend::ProcessContainer; + config.egress_proxy = true; + config.egress_proxy_addr = "10.0.0.1:18080".into(); + let backend = MxcComputeBackend::new_mocked(config); + let err = backend + .validate_sandbox_create(&driver_sandbox("sb-nonlocal")) + .unwrap_err(); + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert!( + err.message().contains("127.0.0.1"), + "error should mention 127.0.0.1, got: {}", + err.message() + ); + } + #[test] fn egress_on_isolation_session_is_rejected() { let mut config = demo_config( @@ -1062,8 +1098,16 @@ mod lifecycle_tests { .unwrap() .is_empty() ); - assert_eq!(recorded["network"]["proxy"]["host"], "127.0.0.1"); - assert_eq!(recorded["network"]["proxy"]["port"], 18080); + // MXC 0.6.0-alpha accepts only {"proxy": {"localhost": N}}. + assert_eq!(recorded["network"]["proxy"]["localhost"], 18080); + assert!( + recorded["network"]["proxy"].get("host").is_none(), + "proxy must not contain 'host' key" + ); + assert!( + recorded["network"]["proxy"].get("port").is_none(), + "proxy must not contain 'port' key" + ); let reg = backend.registry.lock().await; let entry = reg.get("sb-egress").expect("registry entry"); diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs index 033e1d3365..917829a5c1 100644 --- a/crates/openshell-driver-mxc/src/mxc.rs +++ b/crates/openshell-driver-mxc/src/mxc.rs @@ -94,16 +94,19 @@ pub struct MxcProcess { } fn network_json(network: &MxcNetwork) -> serde_json::Value { + // MXC 0.6.0-alpha schema accepts ONLY {"proxy": {"localhost": }}. + // {"host": ..., "port": ...} and every other shape is rejected — verified + // empirically against the real wxc-exec 0.6.0-alpha binary via --dry-run. + // See also docs/reference/mxc-compute-driver-design.mdx §network.proxy. + // The MxcNetwork.proxy field remains SocketAddr so callers keep full + // precision; only the port is serialized into the localhost key. let mut value = serde_json::json!({ "defaultPolicy": network.default_policy.as_str(), "allowedHosts": [], "blockedHosts": [], }); if let Some(proxy) = network.proxy { - value["proxy"] = serde_json::json!({ - "host": proxy.ip().to_string(), - "port": proxy.port(), - }); + value["proxy"] = serde_json::json!({ "localhost": proxy.port() }); } value } @@ -755,8 +758,31 @@ mod tests { .unwrap() .is_empty() ); - assert_eq!(config["network"]["proxy"]["host"], "127.0.0.1"); - assert_eq!(config["network"]["proxy"]["port"], 18080); + // MXC 0.6.0-alpha accepts only {"proxy": {"localhost": N}}. + assert_eq!(config["network"]["proxy"]["localhost"], 18080); + assert!( + config["network"]["proxy"].get("host").is_none(), + "proxy must not contain 'host' key" + ); + assert!( + config["network"]["proxy"].get("port").is_none(), + "proxy must not contain 'port' key" + ); + } + + #[test] + fn network_json_emits_localhost_port_shape() { + // MXC 0.6.0-alpha rejects {"host":...,"port":...} and accepts only + // {"proxy": {"localhost": N}} — verified against the real binary via + // --dry-run. This test pins the exact emitted JSON shape. + let network = MxcNetwork { + default_policy: "block".into(), + proxy: Some("127.0.0.1:18080".parse().unwrap()), + }; + let value = network_json(&network); + assert_eq!(value["proxy"]["localhost"], 18080); + assert!(value["proxy"].get("host").is_none()); + assert!(value["proxy"].get("port").is_none()); } #[test] diff --git a/crates/openshell-driver-mxc/src/policy_map/map.rs b/crates/openshell-driver-mxc/src/policy_map/map.rs index 49089b30b2..8af28c1979 100644 --- a/crates/openshell-driver-mxc/src/policy_map/map.rs +++ b/crates/openshell-driver-mxc/src/policy_map/map.rs @@ -172,16 +172,33 @@ fn build_split_mxc_config( // Direct egress is blocked; all outbound flows through the OpenShell proxy. // allowedHosts is intentionally empty — the proxy enforces the full policy. + // + // MXC 0.6.0-alpha schema accepts ONLY {"proxy": {"localhost": }}. + // {"host": ..., "port": ...} and every other shape is rejected — verified + // empirically against the real wxc-exec 0.6.0-alpha binary via --dry-run. + // See also docs/reference/mxc-compute-driver-design.mdx §network.proxy. + if proxy_supported && proxy_addr.ip() != std::net::IpAddr::from([127, 0, 0, 1]) { + add_loss( + items, + "network.proxy", + "error", + &format!( + "MXC schema 0.6.0-alpha can only express a localhost port \ + ({{\"localhost\": N}}); non-127.0.0.1 redirect address {} \ + is not representable.", + proxy_addr + ), + "per-sandbox egress attribution", + "The redirect cannot be emitted; use a 127.0.0.1:PORT address.", + ); + } let mut network = json!({ "defaultPolicy": "block", "allowedHosts": [], "blockedHosts": [], }); - if proxy_supported { - network["proxy"] = json!({ - "host": proxy_addr.ip().to_string(), - "port": proxy_addr.port(), - }); + if proxy_supported && proxy_addr.ip() == std::net::IpAddr::from([127, 0, 0, 1]) { + network["proxy"] = json!({ "localhost": proxy_addr.port() }); } let mut config = json!({ diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs b/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs index 30de7157ea..4d3a3cfa5c 100644 --- a/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs +++ b/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs @@ -212,8 +212,10 @@ fn all_example_policies_split_with_lossless_invariants() { assert_eq!(cfg["network"]["defaultPolicy"], "block"); assert!(str_list(&cfg["network"]["allowedHosts"]).is_empty()); - assert_eq!(cfg["network"]["proxy"]["host"], "127.0.0.1"); - assert_eq!(cfg["network"]["proxy"]["port"], 18080); + // MXC 0.6.0-alpha accepts only {"proxy": {"localhost": N}}. + assert_eq!(cfg["network"]["proxy"]["localhost"], 18080); + assert!(cfg["network"]["proxy"].get("host").is_none()); + assert!(cfg["network"]["proxy"].get("port").is_none()); assert!( result.loss.iter().all(|i| i.severity != "error"), "processcontainer split must not emit error losses for {}: {:?}", @@ -277,9 +279,23 @@ fn split_policy_routes_network_to_proxy() { let result = split_policy(&policy, &opts).expect("split_policy returns Some when addr is set"); let cfg = &result.mxc_config; - // Proxy redirect is emitted. - assert_eq!(cfg["network"]["proxy"]["host"], "127.0.0.2"); - assert_eq!(cfg["network"]["proxy"]["port"], 8080); + // Proxy redirect is emitted. 127.0.0.2 is not the loopback 127.0.0.1 so + // the mapper records an error loss and omits the proxy block entirely. + // (MXC 0.6.0-alpha can only encode {"localhost": N}; non-127.0.0.1 is + // not representable.) + assert!( + cfg["network"].get("proxy").is_none() || cfg["network"]["proxy"].is_null(), + "non-127.0.0.1 redirect must NOT produce a proxy block: {:?}", + cfg["network"].get("proxy") + ); + let has_proxy_loss = result + .loss + .iter() + .any(|i| i.path == "network.proxy" && i.severity == "error"); + assert!( + has_proxy_loss, + "non-127.0.0.1 redirect must produce an error loss item" + ); // Direct egress is blocked; allowedHosts is empty (proxy enforces the list). assert_eq!(cfg["network"]["defaultPolicy"], "block"); @@ -390,3 +406,87 @@ fn network_only_policy_has_empty_filesystem() { vec!["api.anthropic.com".to_owned()] ); } + +// ── New tests: proxy JSON shape and non-127.0.0.1 guard ────────────────────── + +#[test] +fn split_with_loopback_addr_emits_localhost_port_shape() { + // MXC 0.6.0-alpha accepts ONLY {"proxy": {"localhost": N}}. + // Verified against the real wxc-exec 0.6.0-alpha binary via --dry-run. + let path = examples_root().join("sandbox-policy-quickstart/policy.yaml"); + let yaml = std::fs::read_to_string(&path).expect("read quickstart"); + let policy = parse_sandbox_policy(&yaml).expect("parse quickstart"); + + let opts = MxcMappingOptions { + containment: "processcontainer".to_owned(), + proxy_redirect: Some("127.0.0.1:18080".parse().unwrap()), + ..Default::default() + }; + let result = split_policy(&policy, &opts).expect("split returns Some"); + let cfg = &result.mxc_config; + + assert_eq!( + cfg["network"]["proxy"]["localhost"], 18080, + "proxy must use {{\"localhost\": N}} shape" + ); + assert!( + cfg["network"]["proxy"].get("host").is_none(), + "proxy must not contain 'host' key" + ); + assert!( + cfg["network"]["proxy"].get("port").is_none(), + "proxy must not contain 'port' key" + ); + // No error losses — 127.0.0.1 is representable. + assert!( + result.loss.iter().all(|i| i.severity != "error"), + "127.0.0.1 proxy must not emit error losses: {:?}", + result + .loss + .iter() + .filter(|i| i.severity == "error") + .collect::>() + ); +} + +#[test] +fn split_with_non_loopback_addr_emits_error_loss_and_no_proxy_block() { + // Non-127.0.0.1 redirect addresses are not representable in MXC 0.6.0-alpha. + // The mapper must record an error loss and omit the proxy block. + let path = examples_root().join("sandbox-policy-quickstart/policy.yaml"); + let yaml = std::fs::read_to_string(&path).expect("read quickstart"); + let policy = parse_sandbox_policy(&yaml).expect("parse quickstart"); + + let opts = MxcMappingOptions { + containment: "processcontainer".to_owned(), + proxy_redirect: Some("127.0.0.5:18080".parse().unwrap()), + ..Default::default() + }; + let result = split_policy(&policy, &opts).expect("split returns Some"); + let cfg = &result.mxc_config; + + // Proxy block must be absent. + assert!( + cfg["network"].get("proxy").is_none() || cfg["network"]["proxy"].is_null(), + "non-127.0.0.1 redirect must not produce a proxy block: {:?}", + cfg["network"].get("proxy") + ); + + // An error loss for "network.proxy" must be present. + let proxy_loss = result + .loss + .iter() + .find(|i| i.path == "network.proxy" && i.severity == "error"); + assert!( + proxy_loss.is_some(), + "non-127.0.0.1 redirect must produce an error loss item on network.proxy: {:?}", + result.loss + ); + let loss = proxy_loss.unwrap(); + assert_eq!(loss.openshell_feature, "per-sandbox egress attribution"); + assert!( + loss.message.contains("localhost"), + "loss message should mention 'localhost': {}", + loss.message + ); +} From ac2a36394fbb503df219f415c5d29998e9f878b5 Mon Sep 17 00:00:00 2001 From: Giedrius Burachas Date: Thu, 11 Jun 2026 19:00:20 -0700 Subject: [PATCH 11/19] fix(driver-mxc): serialize isolation_session stop/deprovision as unit variants Empirical contract finding from the real test lane (build 26300.8553, wxc-exec 2026-06-10): the stop and deprovision experimental blocks are unit variants in the wxc-exec schema and must serialize as null; sending {} is rejected with malformed_request (invalid type: map, expected unit), while provision/start accept maps. The production invoker, the real-lane test, the probe script, and the e2e runner all sent {} - the driver could provision and run an agent but never stop or delete an isolation-session sandbox against this build. Pinned by a unit test. Signed-off-by: Giedrius Burachas (cherry picked from commit 0df39ca0b22ebc21eb965b2b567a5b2cac26af32) Signed-off-by: Jamie King --- .../examples/probe-mxc-host.ps1 | 338 ++++++++ .../examples/run-mxc-e2e.ps1 | 476 +++++++++++ crates/openshell-driver-mxc/src/mxc.rs | 34 +- .../tests/wxc_exec_real.rs | 780 ++++++++++++++++++ 4 files changed, 1625 insertions(+), 3 deletions(-) create mode 100644 crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 create mode 100644 crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 create mode 100644 crates/openshell-driver-mxc/tests/wxc_exec_real.rs diff --git a/crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 b/crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 new file mode 100644 index 0000000000..9ea7e776d4 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 @@ -0,0 +1,338 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# probe-mxc-host.ps1 - Emit a JSON capability report for this MXC host. +# +# PowerShell 5.1-compatible (no && / || / ternary operators). +# +# Usage: +# powershell -NoProfile -ExecutionPolicy Bypass -File .\probe-mxc-host.ps1 +# powershell -NoProfile -ExecutionPolicy Bypass -File .\probe-mxc-host.ps1 -OutFile caps.json +# +# The script is read-only except for a per-run temp directory. It never enables +# OS features or modifies system state. +# +# Exit codes: +# 0 - report emitted (even if backends are unavailable) +# 1 - unexpected error (should not happen on a healthy box) + +[CmdletBinding()] +param( + [string] $WxcExecPath = "C:\mxc\wxc-exec.exe", + [string] $OutFile, + # Emit the complete JSON report to stdout. Default output is a short + # human-readable summary (the full report still goes to -OutFile if set). + [switch] $Full +) + +$ErrorActionPreference = "Stop" +# Prevent PS 7+ from turning native non-zero exits into terminating errors. +$PSNativeCommandUseErrorActionPreference = $false + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +function Invoke-Native([string[]] $ArgList) { + # Run a native exe and capture all output regardless of exit code. + # In PowerShell 5.1, a non-zero exit from a native exe can emit + # ErrorRecord objects into the output stream when $ErrorActionPreference + # is Stop (via NativeCommandError). We temporarily relax the preference + # and collect both String and ErrorRecord outputs into a single string. + $saved = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $raw = & $ArgList[0] $ArgList[1..($ArgList.Length - 1)] 2>&1 + $code = $LASTEXITCODE + $text = ($raw | ForEach-Object { + if ($_ -is [System.Management.Automation.ErrorRecord]) { + $_.ToString() + } else { + $_ + } + }) -join "`n" + return @{ ExitCode = $code; Output = $text } + } finally { + $ErrorActionPreference = $saved + } +} + +function Invoke-WxcDryRun([string] $wxc, [hashtable] $config) { + $json = $config | ConvertTo-Json -Depth 20 -Compress + $bytes = [System.Text.Encoding]::UTF8.GetBytes($json) + $b64 = [Convert]::ToBase64String($bytes) + return Invoke-Native @($wxc, "--config-base64", $b64, "--dry-run") +} + +function Invoke-WxcPhase([string] $wxc, [hashtable] $config, [switch] $Experimental) { + $json = $config | ConvertTo-Json -Depth 20 -Compress + $bytes = [System.Text.Encoding]::UTF8.GetBytes($json) + $b64 = [Convert]::ToBase64String($bytes) + if ($Experimental) { + return Invoke-Native @($wxc, "--config-base64", $b64, "--experimental") + } else { + return Invoke-Native @($wxc, "--config-base64", $b64) + } +} + +function Invoke-WxcProbe([string] $wxc) { + return Invoke-Native @($wxc, "--probe") +} + +# ── OS info ─────────────────────────────────────────────────────────────────── + +$osVersion = [System.Environment]::OSVersion.Version +$osBuild = $osVersion.Build +$osRevision = 0 +try { + $ubr = (Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion" -ErrorAction SilentlyContinue).UBR + if ($null -ne $ubr) { $osRevision = $ubr } +} catch {} +$osBuildFull = "$osBuild.$osRevision" +$isoSessionMinBuild = 26300 +$isoSessionMinRevision = 8553 + +# ── wxc-exec info ───────────────────────────────────────────────────────────── + +$wxcInfo = @{ + path = $WxcExecPath + exists = $false + size = $null + mtime = $null +} + +if (Test-Path $WxcExecPath) { + $item = Get-Item $WxcExecPath + $wxcInfo.exists = $true + $wxcInfo.size = $item.Length + $wxcInfo.mtime = $item.LastWriteTime.ToString("o") +} + +# ── Probe section ───────────────────────────────────────────────────────────── + +$probeOutput = $null +$dryRunExitCode = $null +$dryRunOutput = $null +$pcTrialResult = "absent" +$pcTrialMessage = "wxc-exec not found" +$isoTrialResult = "absent" +$isoTrialMessage = "wxc-exec not found" + +if ($wxcInfo.exists) { + # --probe + $probeResult = Invoke-WxcProbe -wxc $WxcExecPath + $probeOutput = $probeResult.Output + + # dry-run trial (minimal processcontainer config) + $dryConfig = @{ + version = "0.6.0-alpha" + containerId = "probe-dryrun" + containment = "processcontainer" + process = @{ + commandLine = "cmd /c exit 0" + cwd = "%TEMP%" + timeout = 0 + } + filesystem = @{ + readwritePaths = @("%TEMP%") + } + } + $dryResult = Invoke-WxcDryRun -wxc $WxcExecPath -config $dryConfig + $dryRunExitCode = $dryResult.ExitCode + $dryRunOutput = $dryResult.Output + + # processcontainer one-shot trial + $pcConfig = @{ + version = "0.6.0-alpha" + containerId = "probe-pc-oneshot" + containment = "processcontainer" + process = @{ + commandLine = "cmd /c exit 0" + cwd = "%TEMP%" + timeout = 10 + } + filesystem = @{ + readwritePaths = @("%TEMP%") + } + processContainer = @{ + leastPrivilege = $false + } + } + $pcResult = Invoke-WxcPhase -wxc $WxcExecPath -config $pcConfig + $pcOutput = $pcResult.Output + $pcOutputLower = $pcOutput.ToLower() + + if ($pcResult.ExitCode -eq 0) { + $pcTrialResult = "works" + $pcTrialMessage = "processcontainer one-shot exited 0" + } elseif ($pcOutputLower -match "backend_error" -or $pcOutputLower -match "e_notimpl" -or $pcOutputLower -match "velocity") { + $pcTrialResult = "backend_error" + # Try to extract the message from the JSON envelope. + $pcTrialMessage = "backend_error: velocity keys not enabled (E_NOTIMPL)" + try { + $envelope = $pcOutput | ConvertFrom-Json + if ($null -ne $envelope.error) { + $pcTrialMessage = "backend_error: $($envelope.error.message)" + } + } catch {} + } else { + $pcTrialResult = "error" + if ([string]::IsNullOrWhiteSpace($pcOutput)) { + $pcTrialMessage = "exit $($pcResult.ExitCode) with no output captured" + } else { + $pcTrialMessage = "exit $($pcResult.ExitCode): $pcOutput" + } + } + + # isolation_session provision trial + $isoConfig = @{ + version = "0.6.0-alpha" + phase = "provision" + containment = "isolation_session" + filesystem = @{ + readwritePaths = @() + readonlyPaths = @() + } + experimental = @{ + isolation_session = @{ + configurationId = "composable" + provision = @{} + } + } + } + $isoResult = Invoke-WxcPhase -wxc $WxcExecPath -config $isoConfig -Experimental + $isoOutput = $isoResult.Output + $isoOutputLower = $isoOutput.ToLower() + + if ($isoOutputLower -match "backend_unavailable" -or $isoOutputLower -match "0x80040154") { + $isoTrialResult = "unavailable" + $isoTrialMessage = "backend_unavailable: IsoSessionApp.dll absent or OS build < 26300.8553" + } elseif ($isoResult.ExitCode -eq 0) { + # Provision succeeded — deprovision immediately to avoid orphaning. + $isoTrialResult = "live" + $isoTrialMessage = "isolation_session provision succeeded" + $sandboxId = $null + try { + $envelope = $isoOutput | ConvertFrom-Json + if ($null -ne $envelope.result) { + $sandboxId = $envelope.result.sandboxId + } + } catch {} + + if ($null -ne $sandboxId) { + # Stop first (a provisioned-but-unstarted session may still accept it; + # ignore failures), then deprovision. Surface the deprovision error + # text — an orphaned session blocks the single-session backend. + $stopConfig = @{ + version = "0.6.0-alpha" + phase = "stop" + sandboxId = $sandboxId + experimental = @{ + isolation_session = @{ + # Unit variant: serialize as null, not {} (malformed_request otherwise). + stop = $null + } + } + } + Invoke-WxcPhase -wxc $WxcExecPath -config $stopConfig -Experimental | Out-Null + $deprovConfig = @{ + version = "0.6.0-alpha" + phase = "deprovision" + sandboxId = $sandboxId + experimental = @{ + isolation_session = @{ + deprovision = $null + } + } + } + $deprovResult = Invoke-WxcPhase -wxc $WxcExecPath -config $deprovConfig -Experimental + if ($deprovResult.ExitCode -eq 0) { + $isoTrialMessage = "isolation_session live (provisioned $sandboxId, deprovisioned cleanly)" + } else { + $snippet = $deprovResult.Output + if ($snippet.Length -gt 200) { $snippet = $snippet.Substring(0, 200) } + $isoTrialMessage = "isolation_session live (provisioned $sandboxId; deprovision FAILED exit $($deprovResult.ExitCode): $snippet -- clean up manually before running lifecycle tests)" + } + } + } else { + $isoTrialResult = "error" + $isoTrialMessage = "exit $($isoResult.ExitCode): $isoOutput" + } +} + +# ── Verdicts ────────────────────────────────────────────────────────────────── + +$pcVerdict = $null +if ($pcTrialResult -eq "works") { + $pcVerdict = "live" +} else { + $pcVerdict = "unavailable: $pcTrialMessage" +} + +$isoVerdict = $null +if ($isoTrialResult -eq "live") { + $isoVerdict = "live" +} else { + $isoVerdict = "unavailable: $isoTrialMessage" +} + +$dryRunVerdict = $null +if ($null -eq $dryRunExitCode) { + $dryRunVerdict = "unavailable: wxc-exec not found" +} elseif ($dryRunExitCode -eq 0) { + $dryRunVerdict = "ok" +} else { + $dryRunVerdict = "failed: exit $dryRunExitCode" +} + +# ── Assemble report ─────────────────────────────────────────────────────────── + +$report = [ordered]@{ + generatedAt = (Get-Date).ToString("o") + host = [ordered]@{ + osBuild = $osBuildFull + osBuildNumber = $osBuild + osRevision = $osRevision + isoSessionBuildRequirement = "${isoSessionMinBuild}.${isoSessionMinRevision}" + meetsIsoBuildReq = ($osBuild -gt $isoSessionMinBuild) -or + ($osBuild -eq $isoSessionMinBuild -and $osRevision -ge $isoSessionMinRevision) + } + wxcExec = $wxcInfo + probeOutput = $probeOutput + dryRun = [ordered]@{ + exitCode = $dryRunExitCode + output = $dryRunOutput + } + processcontainerTrial = [ordered]@{ + result = $pcTrialResult + message = $pcTrialMessage + } + isolationSessionTrial = [ordered]@{ + result = $isoTrialResult + message = $isoTrialMessage + } + verdicts = [ordered]@{ + processcontainer = $pcVerdict + isolation_session = $isoVerdict + dryRun = $dryRunVerdict + } +} + +$json = $report | ConvertTo-Json -Depth 10 + +if ($Full) { + Write-Output $json +} else { + $met = "not met" + if ($report.host.meetsIsoBuildReq) { $met = "met" } + Write-Host "MXC host probe - OS build $osBuildFull (isolation_session requires $($report.host.isoSessionBuildRequirement): $met)" + Write-Host "wxc-exec: $WxcExecPath (exists=$($wxcInfo.exists))" + Write-Host "verdicts:" + Write-Host " processcontainer : $pcVerdict" + Write-Host " isolation_session : $isoVerdict" + Write-Host " dry-run : $dryRunVerdict" + Write-Host "(re-run with -Full for the complete JSON report, or -OutFile caps.json to save it)" +} + +if ($OutFile) { + $json | Out-File -FilePath $OutFile -Encoding utf8 + Write-Host "Report written to $OutFile" -ForegroundColor Cyan +} diff --git a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 new file mode 100644 index 0000000000..276e6f5145 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 @@ -0,0 +1,476 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# run-mxc-e2e.ps1 - MXC e2e scenario runner. +# +# Starts the gateway ONCE, runs a table of policy scenarios, emits per-scenario +# PASS/FAIL/SKIP(reason), prints a summary table, and exits non-zero only on +# FAIL. Reuses the gateway-start / CLI-register / teardown pattern from +# run-demo.ps1. +# +# PowerShell 5.1-compatible (no && / || / ternary operators). +# +# Usage examples: +# +# # Real mode (probe-gated — backends that are absent are SKIPped): +# powershell -NoProfile -ExecutionPolicy Bypass -File .\run-mxc-e2e.ps1 +# +# # Mock mode (wiring-only; no real wxc-exec or enforcement): +# powershell -NoProfile -ExecutionPolicy Bypass -File .\run-mxc-e2e.ps1 -Mock +# +# # Choose backend / filter scenarios: +# .\run-mxc-e2e.ps1 -Backend process_container -Scenario fs-rw-positive-negative +# +# Scenarios & expected verdicts: +# fs-rw-positive-negative - rw grant on DemoDir; in-policy write succeeds. +# Both backends; skipped when backend not live (non-mock). +# fs-readonly - ro grant on a source dir + rw on DemoDir; +# write to ro dir should be denied. +# Both backends; skipped when backend not live. +# fs-default-deny-empty - empty filesystem policy; every write denied. +# processcontainer only (isolation_session has no deny +# primitive); skipped on isolation_session. +# network-policy-rejected - rw grant + network_policies rule; +# sandbox create must FAIL (invalid_argument). +# Runs on ANY backend including mock — never skips. + +[CmdletBinding()] +param( + [string] $DemoDir = "C:\work\openshell-mxc-e2e", + [string] $WxcExecPath = "C:\mxc\wxc-exec.exe", + [ValidateSet("isolation_session", "process_container")] + [string] $Backend = "process_container", + [string] $Scenario, + [int] $Port = 17670, + [string] $GatewayName = "openshell-mxc-e2e", + [switch] $Mock, + [switch] $KeepRunning +) + +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $false + +$here = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path } + +function Step([string]$m) { Write-Host "`n=== $m ===" -ForegroundColor Cyan } +function Info([string]$m) { Write-Host " $m" } +function Ok([string]$m) { Write-Host "[OK] $m" -ForegroundColor Green } +function Bad([string]$m) { Write-Host "[FAIL] $m" -ForegroundColor Red } +function Skip([string]$m) { Write-Host "[SKIP] $m" -ForegroundColor Yellow } +function Warn([string]$m) { Write-Host "[WARN] $m" -ForegroundColor Yellow } + +# ── Pre-flight ──────────────────────────────────────────────────────────────── + +# In real mode, assert OPENSHELL_MXC_MOCK_WXC is NOT set. +# A stale mock env var would silently re-mock a run that should be real. +if (-not $Mock) { + if ($env:OPENSHELL_MXC_MOCK_WXC -eq "1") { + throw "OPENSHELL_MXC_MOCK_WXC=1 is set but -Mock was not passed. " + + "A stale mock env var would silently re-mock a real run. " + + "Unset OPENSHELL_MXC_MOCK_WXC or pass -Mock." + } +} + +$gateway = Join-Path $here "openshell-gateway.exe" +$cli = Join-Path $here "openshell.exe" +$toml = Join-Path $here "mxc-gateway.toml" +$policyDir = Join-Path $here "e2e-policies" + +foreach ($f in @($gateway, $cli, $toml)) { + if (-not (Test-Path $f)) { + throw "Missing artifact: $f`nBuild first or run from a demo-package folder." + } +} +if (-not (Test-Path $policyDir)) { + throw "e2e-policies/ directory not found at $policyDir" +} + +# ── Backend probe ───────────────────────────────────────────────────────────── + +# Returns a verdict hash for a given backend: {Live: bool, Reason: string} +function Probe-Backend([string] $backendName, [string] $wxc) { + if ($Mock) { + # In mock mode all backends are "live" — enforcement is simulated. + return @{ Live = $true; Reason = "mock mode" } + } + if (-not (Test-Path $wxc)) { + return @{ Live = $false; Reason = "wxc-exec not found at $wxc" } + } + + if ($backendName -eq "process_container") { + $config = @{ + version = "0.6.0-alpha" + containerId = "e2e-probe-pc" + containment = "processcontainer" + process = @{ + commandLine = "cmd /c exit 0" + cwd = "%TEMP%" + timeout = 10 + } + filesystem = @{ readwritePaths = @("%TEMP%") } + processContainer = @{ leastPrivilege = $false } + } + $json = $config | ConvertTo-Json -Depth 20 -Compress + $bytes = [System.Text.Encoding]::UTF8.GetBytes($json) + $b64 = [Convert]::ToBase64String($bytes) + $outObj = & $wxc --config-base64 $b64 2>&1 + $exitCode = $LASTEXITCODE + $output = ($outObj -join "`n").ToLower() + if ($exitCode -eq 0) { + return @{ Live = $true; Reason = "process_container probe exit 0" } + } + $reason = "process_container unavailable: exit $exitCode" + if ($output -match "backend_error" -or $output -match "e_notimpl" -or $output -match "velocity") { + $reason = "process_container backend_error (velocity keys not enabled)" + } + return @{ Live = $false; Reason = $reason } + } + + if ($backendName -eq "isolation_session") { + $config = @{ + version = "0.6.0-alpha" + phase = "provision" + containment = "isolation_session" + filesystem = @{ readwritePaths = @(); readonlyPaths = @() } + experimental = @{ + isolation_session = @{ + configurationId = "composable" + provision = @{} + } + } + } + $json = $config | ConvertTo-Json -Depth 20 -Compress + $bytes = [System.Text.Encoding]::UTF8.GetBytes($json) + $b64 = [Convert]::ToBase64String($bytes) + $outObj = & $wxc --config-base64 $b64 --experimental 2>&1 + $exitCode = $LASTEXITCODE + $output = ($outObj -join "`n").ToLower() + if ($output -match "backend_unavailable" -or $output -match "0x80040154") { + return @{ Live = $false; Reason = "isolation_session backend_unavailable (IsoSessionApp.dll absent)" } + } + if ($exitCode -ne 0) { + return @{ Live = $false; Reason = "isolation_session probe failed: exit $exitCode" } + } + # Provision succeeded — deprovision immediately. + $sandboxId = $null + try { + $rawOut = ($outObj -join "`n") + $parsed = $rawOut | ConvertFrom-Json + $sandboxId = $parsed.result.sandboxId + } catch {} + if ($null -ne $sandboxId) { + $deprovConfig = @{ + version = "0.6.0-alpha" + phase = "deprovision" + sandboxId = $sandboxId + experimental = @{ + # Unit variant: null, not @{} (malformed_request otherwise). + isolation_session = @{ deprovision = $null } + } + } + $deprovJson = $deprovConfig | ConvertTo-Json -Depth 20 -Compress + $deprovBytes = [System.Text.Encoding]::UTF8.GetBytes($deprovJson) + $deprovB64 = [Convert]::ToBase64String($deprovBytes) + & $wxc --config-base64 $deprovB64 --experimental 2>&1 | Out-Null + } + return @{ Live = $true; Reason = "isolation_session probe: provisioned and deprovisioned" } + } + + return @{ Live = $false; Reason = "unknown backend: $backendName" } +} + +# ── Mode setup ──────────────────────────────────────────────────────────────── + +Step "Pre-flight (mode=$(if ($Mock) {'MOCK'} else {'REAL'}), backend=$Backend)" + +if ($Mock) { + $env:OPENSHELL_MXC_MOCK_WXC = "1" + Info "OPENSHELL_MXC_MOCK_WXC=1 — mock mode: enforcement simulated" +} else { + Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue + if (-not (Test-Path $WxcExecPath)) { + throw "wxc-exec not found at '$WxcExecPath'. Pass -WxcExecPath or use -Mock." + } + $env:OPENSHELL_WXC_EXEC_PATH = $WxcExecPath + Info "wxc-exec: $WxcExecPath" +} + +# Patch the TOML copy for backend + wxc_exec_path (mirrors run-demo.ps1). +$tomlText = Get-Content $toml -Raw +$backendLine = "backend = `"$Backend`"" +if ($tomlText -match '(?m)^\s*#?\s*backend\s*=') { + $tomlText = [regex]::Replace($tomlText, '(?m)^\s*#?\s*backend\s*=.*$', $backendLine) +} else { + $tomlText = [regex]::Replace($tomlText, '(?m)^\[openshell\.drivers\.mxc\]\s*$', "[openshell.drivers.mxc]`r`n$backendLine") +} +if (-not $Mock) { + $escaped = $WxcExecPath.Replace('\', '\\') + $wxcLine = "wxc_exec_path = `"$escaped`"" + if ($tomlText -match '(?m)^\s*#?\s*wxc_exec_path\s*=') { + $tomlText = [regex]::Replace($tomlText, '(?m)^\s*#?\s*wxc_exec_path\s*=.*$', $wxcLine) + } else { + $tomlText = [regex]::Replace($tomlText, '(?m)^\[openshell\.drivers\.mxc\]\s*$', "[openshell.drivers.mxc]`r`n$wxcLine") + } +} +Set-Content $toml -Value $tomlText -Encoding UTF8 +Info "patched $(Split-Path $toml -Leaf): backend=$Backend" + +# Probe backend liveness now (used by scenario gate below). +$backendProbe = Probe-Backend -backendName $Backend -wxc $WxcExecPath +if ($backendProbe.Live) { + Ok "Backend '$Backend' is live: $($backendProbe.Reason)" +} else { + Warn "Backend '$Backend' is not live: $($backendProbe.Reason)" + Warn "Enforcement scenarios will SKIP; network-reject scenario will still run." +} + +# ── Port check ──────────────────────────────────────────────────────────────── + +Step "Check gateway port $Port" +$busy = Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue +if ($busy) { + throw "port $Port in use (pid $($busy.OwningProcess)). Stop stale gateway first." +} +Ok "port $Port free" + +# ── Prepare DemoDir ─────────────────────────────────────────────────────────── + +Step "Prepare DemoDir $DemoDir" +New-Item -ItemType Directory -Force $DemoDir | Out-Null +Ok "DemoDir ready" + +$env:OPENSHELL_DRIVERS = "mxc" +$env:OPENSHELL_MXC_SHARE_DIR = $DemoDir + +# ── Start gateway ───────────────────────────────────────────────────────────── + +Step "Start gateway" +$gwLog = Join-Path $here "gateway.e2e.log" +$gwErrLog = "$gwLog.err" +Remove-Item $gwLog, $gwErrLog -Force -ErrorAction SilentlyContinue + +$gw = Start-Process -FilePath $gateway ` + -ArgumentList @("--disable-tls", "--config", $toml, "--log-level", "info") ` + -WorkingDirectory $here -PassThru -NoNewWindow ` + -RedirectStandardOutput $gwLog -RedirectStandardError $gwErrLog + +Info "gateway pid $($gw.Id); logs: $gwLog" + +$results = @() + +try { + # Wait for listening + $deadline = (Get-Date).AddSeconds(30) + $ready = $false + while ((Get-Date) -lt $deadline) { + if ($gw.HasExited) { + Get-Content $gwLog, $gwErrLog -ErrorAction SilentlyContinue | ForEach-Object { Info $_ } + throw "gateway exited early (code $($gw.ExitCode)). See log." + } + if (Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue) { + $ready = $true + break + } + Start-Sleep -Milliseconds 500 + } + if (-not $ready) { throw "gateway did not start within 30 s." } + Ok "gateway listening on $Port" + + # Register CLI + Step "Register CLI" + $env:OPENSHELL_GATEWAY = "" + try { & $cli gateway add "http://127.0.0.1:$Port" --local --name $GatewayName 2>&1 | ForEach-Object { Info $_ } } + catch { Info "gateway add: $($_.Exception.Message) (continuing)" } + try { & $cli gateway select $GatewayName 2>&1 | ForEach-Object { Info $_ } } + catch { Info "gateway select: $($_.Exception.Message) (continuing)" } + Ok "CLI registered" + + # ── Scenario definitions ────────────────────────────────────────────────── + # + # Each scenario is a hashtable: + # Name - unique identifier + # PolicyFile - path to the policy YAML fixture + # Backends - list: "both" / "process_container" / "isolation_session" + # ExpectFail - $true means `sandbox create` itself must fail (invalid_argument) + # Description - human-readable label + + $allScenarios = @( + @{ + Name = "fs-rw-positive-negative" + PolicyFile = Join-Path $policyDir "fs-rw.yaml" + Backends = "both" + ExpectFail = $false + Description = "rw grant on DemoDir; in-policy write should succeed" + }, + @{ + Name = "fs-readonly" + PolicyFile = Join-Path $policyDir "fs-readonly.yaml" + Backends = "both" + ExpectFail = $false + Description = "ro grant + rw share; write to ro dir should be denied" + }, + @{ + Name = "fs-default-deny-empty" + PolicyFile = Join-Path $policyDir "fs-empty.yaml" + Backends = "process_container" + ExpectFail = $false + Description = "empty filesystem policy; all writes denied (process_container only)" + }, + @{ + Name = "network-policy-rejected" + PolicyFile = Join-Path $policyDir "network-reject.yaml" + Backends = "both" + ExpectFail = $true + Description = "network_policies rule causes sandbox create to fail (no live backend needed)" + } + ) + + # Apply optional scenario filter. + if ($Scenario) { + $filtered = $allScenarios | Where-Object { $_.Name -eq $Scenario } + if ($filtered.Count -eq 0) { + throw "Scenario '$Scenario' not found. Available: $(($allScenarios | ForEach-Object { $_.Name }) -join ', ')" + } + $allScenarios = $filtered + } + + # ── Run scenarios ───────────────────────────────────────────────────────── + + foreach ($sc in $allScenarios) { + Step "Scenario: $($sc.Name)" + Info $sc.Description + + # Backend gate: skip enforcement scenarios when backend not live (and not mock and not ExpectFail). + $skipReason = $null + if (-not $sc.ExpectFail) { + $backendMatches = ($sc.Backends -eq "both") -or ($sc.Backends -eq $Backend) + if (-not $backendMatches) { + $skipReason = "scenario requires backend=$($sc.Backends); current backend=$Backend" + } elseif (-not $backendProbe.Live -and -not $Mock) { + $skipReason = "backend not live: $($backendProbe.Reason)" + } + } + + if ($null -ne $skipReason) { + Skip "$($sc.Name): $skipReason" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "SKIP"; Reason = $skipReason } + continue + } + + # Policy file must exist. + if (-not (Test-Path $sc.PolicyFile)) { + Bad "$($sc.Name): policy fixture not found at $($sc.PolicyFile)" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "policy fixture missing" } + continue + } + + # Patch the TOML agent_command to a simple one-liner appropriate for + # the scenario. For ExpectFail scenarios the command never runs; for + # others write to DemoDir. + $target = Join-Path $DemoDir "$($sc.Name)-result.txt" + Remove-Item $target -Force -ErrorAction SilentlyContinue + $targetFwd = $target.Replace('\', '/') + $demoDirFwd = $DemoDir.Replace('\', '/') + $agentCmd = "cmd /c echo ok > `"$targetFwd`"" + $agentCmdJson = "[`"cmd`", `"/c`", `"echo ok > $targetFwd`"]" + + $tomlRuntime = Get-Content $toml -Raw + if ($tomlRuntime -match '(?m)^\s*agent_command\s*=') { + $tomlRuntime = [regex]::Replace($tomlRuntime, '(?ms)^\s*agent_command\s*=.*?(?=\n\s*[^\s\[#]|\n\s*\[|\Z)', "agent_command = $agentCmdJson") + } + if ($tomlRuntime -match '(?m)^\s*share_dir\s*=') { + $tomlRuntime = [regex]::Replace($tomlRuntime, '(?m)^\s*share_dir\s*=.*$', "share_dir = `"$demoDirFwd`"") + } + if ($tomlRuntime -match '(?m)^\s*agent_cwd\s*=') { + $tomlRuntime = [regex]::Replace($tomlRuntime, '(?m)^\s*agent_cwd\s*=.*$', "agent_cwd = `"$demoDirFwd`"") + } + Set-Content $toml -Value $tomlRuntime -Encoding UTF8 + + # Run sandbox create. + $createOut = $null + $createExitCode = 0 + try { + $createOut = & $cli sandbox create --name $sc.Name --policy $sc.PolicyFile --no-tty -- exit 2>&1 + $createExitCode = $LASTEXITCODE + } catch { + $createOut = $_.Exception.Message + $createExitCode = 1 + } + $createOutStr = ($createOut -join "`n") + Info "create exit: $createExitCode" + + # Delete sandbox (best-effort; no-op if create failed). + try { & $cli sandbox delete $sc.Name 2>&1 | Out-Null } catch {} + + # Evaluate. + if ($sc.ExpectFail) { + # network-policy-rejected: create must fail. + if ($createExitCode -ne 0) { + Ok "$($sc.Name): create correctly failed (exit $createExitCode)" + Info "output: $createOutStr" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "create failed as expected" } + } else { + Bad "$($sc.Name): create succeeded but should have failed" + Info "output: $createOutStr" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "create succeeded unexpectedly" } + } + } else { + # Wiring check in mock mode: artifact present = pass. + if ($Mock) { + $deadline = (Get-Date).AddSeconds(10) + while ((Get-Date) -lt $deadline -and -not (Test-Path $target)) { + Start-Sleep -Milliseconds 300 + } + if (Test-Path $target) { + Ok "$($sc.Name): in-policy artifact present (mock wiring OK)" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "mock wiring: artifact present" } + } else { + Bad "$($sc.Name): artifact missing in DemoDir (mock wiring FAIL)" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "artifact missing (mock)" } + } + } else { + # Real mode: artifact presence == enforcement worked. + $deadline = (Get-Date).AddSeconds(30) + while ((Get-Date) -lt $deadline -and -not (Test-Path $target)) { + Start-Sleep -Milliseconds 500 + } + if ($createExitCode -eq 0 -and (Test-Path $target)) { + Ok "$($sc.Name): in-policy write succeeded" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "in-policy write produced artifact" } + } else { + Bad "$($sc.Name): FAIL (create=$createExitCode, artifact=$(Test-Path $target))" + Info "createOut: $createOutStr" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "create=$createExitCode, artifact=$(Test-Path $target)" } + } + } + } + } + +} finally { + if ($KeepRunning) { + Info "leaving gateway pid $($gw.Id) running (-KeepRunning)" + } elseif ($gw -and -not $gw.HasExited) { + Step "Cleanup" + Stop-Process -Id $gw.Id -Force -ErrorAction SilentlyContinue + Info "stopped gateway pid $($gw.Id)" + } +} + +# ── Summary table ───────────────────────────────────────────────────────────── + +Step "Summary" +$results | Format-Table -AutoSize + +$failCount = ($results | Where-Object { $_.Result -eq "FAIL" }).Count +$passCount = ($results | Where-Object { $_.Result -eq "PASS" }).Count +$skipCount = ($results | Where-Object { $_.Result -eq "SKIP" }).Count + +Write-Host "PASS=$passCount FAIL=$failCount SKIP=$skipCount" + +if ($failCount -gt 0) { + Write-Host "`nSOME SCENARIOS FAILED" -ForegroundColor Red + exit 1 +} else { + Write-Host "`nALL SCENARIOS PASSED (or SKIPPED)" -ForegroundColor Green + exit 0 +} diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs index 917829a5c1..f64fb621f8 100644 --- a/crates/openshell-driver-mxc/src/mxc.rs +++ b/crates/openshell-driver-mxc/src/mxc.rs @@ -605,6 +605,11 @@ impl WxcExecInvoker { } /// Run the stop phase. + /// + /// `stop`/`deprovision` are **unit** variants in the wxc-exec schema: they + /// must serialize as `null`, not `{}`. Empirical (build 26300.8553, + /// wxc-exec 2026-06-10): `"stop": {}` is rejected with `malformed_request` + /// ("invalid type: map, expected unit"); `provision`/`start` accept maps. pub async fn stop(&self, iso_sandbox_id: &str) -> Result<(), InvokerError> { let config = serde_json::json!({ "version": MXC_SCHEMA_VERSION, @@ -612,14 +617,14 @@ impl WxcExecInvoker { "sandboxId": iso_sandbox_id, "experimental": { "isolation_session": { - "stop": {} + "stop": null } } }); self.run_phase(&config).await } - /// Run the deprovision phase. + /// Run the deprovision phase (unit variant — see [`Self::stop`]). pub async fn deprovision(&self, iso_sandbox_id: &str) -> Result<(), InvokerError> { let config = serde_json::json!({ "version": MXC_SCHEMA_VERSION, @@ -627,7 +632,7 @@ impl WxcExecInvoker { "sandboxId": iso_sandbox_id, "experimental": { "isolation_session": { - "deprovision": {} + "deprovision": null } } }); @@ -804,6 +809,29 @@ mod tests { assert!(config.get("network").is_none()); } + #[test] + fn stop_and_deprovision_serialize_as_unit_variants() { + // Pins the empirical schema contract (test box, build 26300.8553): + // stop/deprovision are unit variants and must be `null`; `{}` is + // rejected with malformed_request "invalid type: map, expected unit". + for phase in ["stop", "deprovision"] { + let config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "phase": phase, + "sandboxId": "iso:wxc-test", + "experimental": { + "isolation_session": { + phase: null + } + } + }); + assert!( + config["experimental"]["isolation_session"][phase].is_null(), + "{phase} must serialize as null (unit variant)" + ); + } + } + #[test] fn invoker_error_maps_backend_unavailable_to_unavailable() { let err = InvokerError::Mxc { diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs new file mode 100644 index 0000000000..e5ca3bb382 --- /dev/null +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -0,0 +1,780 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Real-`wxc-exec` integration tests (Tier 2). +//! +//! These tests drive the actual `wxc-exec.exe` binary — no mock shim. Every +//! test is `#[ignore = "requires real wxc-exec"]` so the regular `cargo test` +//! suite (`windows:test:x64`) never blocks on hardware. Run them with: +//! +//! ```powershell +//! $env:OPENSHELL_WXC_EXEC_PATH = "C:\mxc\wxc-exec.exe" +//! cargo test -p openshell-driver-mxc --test wxc_exec_real -- --ignored --test-threads=1 +//! ``` +//! +//! Two families: +//! +//! **(a) Dry-run contract tests** — exercise `--dry-run` only; pass/fail on +//! schema acceptance. These pass on this box even though no enforcement +//! backend is live (dry-run validates the JSON schema without spinning up the +//! AppContainer or isolation session). +//! +//! **(b) Enforcement tests** — probe-gated; print a human-readable SKIP reason +//! and return early when the backend is not live. The probe distinguishes +//! "binary absent", "backend_error / velocity keys not enabled", and +//! "backend_unavailable". +//! +//! IMPORTANT: `OPENSHELL_MXC_MOCK_WXC` must NOT be set when running this file. +//! The probe-gated enforcement tests assert that it is absent so a stale env +//! var can never silently re-mock a "real" run. + +#![cfg(target_os = "windows")] + +use base64::Engine as _; +use std::path::PathBuf; +use std::process::Command; + +// ── Path resolution ────────────────────────────────────────────────────────── + +/// Resolve the path to `wxc-exec.exe`. +/// +/// Checks `OPENSHELL_WXC_EXEC_PATH` first, then the canonical demo-box +/// location `C:\mxc\wxc-exec.exe`. Returns `None` when neither path exists so +/// callers can skip rather than fail. +fn wxc_path() -> Option { + if let Ok(p) = std::env::var("OPENSHELL_WXC_EXEC_PATH") { + let pb = PathBuf::from(&p); + if pb.exists() { + return Some(pb); + } + // Env var was set but path is absent — still treat as "not found" so + // tests skip with a clear reason rather than erroring on spawn. + eprintln!("SKIP: OPENSHELL_WXC_EXEC_PATH={p} does not exist"); + return None; + } + let default = PathBuf::from(r"C:\mxc\wxc-exec.exe"); + if default.exists() { + return Some(default); + } + None +} + +// ── Dry-run helper ──────────────────────────────────────────────────────────── + +/// Invoke `wxc-exec --config-base64 --dry-run` synchronously. +/// Returns `(exit_code, stdout, stderr)`. +fn dry_run(wxc: &PathBuf, config: &serde_json::Value) -> (i32, String, String) { + let json = serde_json::to_string(config).expect("config serialize"); + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + + let out = Command::new(wxc) + .arg("--config-base64") + .arg(&b64) + .arg("--dry-run") + .output() + .expect("wxc-exec spawn"); + + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + let code = out.status.code().unwrap_or(-1); + (code, stdout, stderr) +} + +// ── (a) Dry-run contract tests ──────────────────────────────────────────────── +// +// These PASS on any box that has the wxc-exec binary — no enforcement backend +// is required because --dry-run only validates the JSON schema. + +/// Minimal processcontainer one-shot config accepted by `--dry-run`. +#[test] +#[ignore = "requires real wxc-exec"] +fn dryrun_accepts_minimal_processcontainer_config() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "containerId": "test-minimal", + "containment": "processcontainer", + "process": { + "commandLine": "cmd /c exit 0", + "cwd": "%TEMP%", + "timeout": 0, + }, + "filesystem": { + "readwritePaths": ["%TEMP%"], + }, + }); + + let (code, stdout, stderr) = dry_run(&wxc, &config); + assert_eq!( + code, 0, + "minimal processcontainer config rejected by --dry-run\nstdout={stdout}\nstderr={stderr}" + ); +} + +/// Network block without proxy (defaultPolicy block, empty host lists) accepted. +#[test] +#[ignore = "requires real wxc-exec"] +fn dryrun_accepts_network_block_without_proxy() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "containerId": "test-net-block", + "containment": "processcontainer", + "process": { + "commandLine": "cmd /c exit 0", + "cwd": "%TEMP%", + "timeout": 0, + }, + "filesystem": { + "readwritePaths": ["%TEMP%"], + }, + "network": { + "defaultPolicy": "block", + "allowedHosts": [], + "blockedHosts": [], + }, + }); + + let (code, stdout, stderr) = dry_run(&wxc, &config); + assert_eq!( + code, 0, + "network block without proxy rejected by --dry-run\nstdout={stdout}\nstderr={stderr}" + ); +} + +/// The ONLY accepted proxy shape in MXC 0.6.0-alpha: `{"localhost": }`. +/// Verified empirically against the real binary — any other shape is rejected +/// with "Request error". +#[test] +#[ignore = "requires real wxc-exec"] +fn dryrun_accepts_localhost_proxy_shape() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "containerId": "test-proxy-localhost", + "containment": "processcontainer", + "process": { + "commandLine": "cmd /c exit 0", + "cwd": "%TEMP%", + "timeout": 0, + }, + "filesystem": { + "readwritePaths": ["%TEMP%"], + }, + "network": { + "defaultPolicy": "block", + "allowedHosts": [], + "blockedHosts": [], + "proxy": { "localhost": 18080 }, + }, + }); + + let (code, stdout, stderr) = dry_run(&wxc, &config); + assert_eq!( + code, 0, + "{{\"localhost\": N}} proxy shape rejected by --dry-run\nstdout={stdout}\nstderr={stderr}" + ); +} + +/// The `{"host": ..., "port": ...}` proxy shape is REJECTED by MXC 0.6.0-alpha. +/// This test guards the schema contract discovered via dry-run bisection. +/// See docs4gtb/mxc-box-capabilities.md §"Schema contract findings". +#[test] +#[ignore = "requires real wxc-exec"] +fn dryrun_rejects_host_port_proxy_shape() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "containerId": "test-proxy-hostport", + "containment": "processcontainer", + "process": { + "commandLine": "cmd /c exit 0", + "cwd": "%TEMP%", + "timeout": 0, + }, + "filesystem": { + "readwritePaths": ["%TEMP%"], + }, + "network": { + "defaultPolicy": "block", + "allowedHosts": [], + "blockedHosts": [], + // MXC 0.6.0-alpha rejects {"host","port"} — verified empirically. + "proxy": { "host": "127.0.0.1", "port": 18080 }, + }, + }); + + let (code, _stdout, _stderr) = dry_run(&wxc, &config); + assert_ne!( + code, 0, + "{{\"host\",\"port\"}} proxy shape was unexpectedly ACCEPTED — \ + schema may have widened in a newer wxc-exec build" + ); +} + +/// Unknown containment value is rejected. +#[test] +#[ignore = "requires real wxc-exec"] +fn dryrun_rejects_unknown_containment() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "containerId": "test-bad-containment", + "containment": "nonsense", + "process": { + "commandLine": "cmd /c exit 0", + "cwd": "%TEMP%", + "timeout": 0, + }, + "filesystem": { + "readwritePaths": ["%TEMP%"], + }, + }); + + let (code, _stdout, _stderr) = dry_run(&wxc, &config); + assert_ne!(code, 0, "unknown containment 'nonsense' should be rejected"); +} + +/// The most important dry-run test: parse the quickstart example policy with +/// `openshell_policy`, run `split_policy` (proxy_redirect 127.0.0.1:18080, +/// containment "processcontainer"), take the resulting `mxc_config`, inject a +/// real process block with a valid cwd, and verify that `--dry-run` exits 0. +/// +/// This proves that the mapper's emitted JSON is accepted by the real binary — +/// the central contract of the policy-mapper integration. +#[test] +#[ignore = "requires real wxc-exec"] +fn dryrun_accepts_split_policy_output() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + // Find the quickstart policy relative to CARGO_MANIFEST_DIR. + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let policy_path = manifest_dir.join("../../examples/sandbox-policy-quickstart/policy.yaml"); + + if !policy_path.exists() { + eprintln!( + "SKIP: quickstart policy not found at {}", + policy_path.display() + ); + return; + } + + let yaml = std::fs::read_to_string(&policy_path).expect("read policy YAML"); + let policy = openshell_policy::parse_sandbox_policy(&yaml).expect("parse quickstart policy"); + + let opts = openshell_driver_mxc::MxcMappingOptions { + containment: "processcontainer".to_string(), + proxy_redirect: Some("127.0.0.1:18080".parse().unwrap()), + ..Default::default() + }; + + let result = openshell_driver_mxc::split_policy(&policy, &opts) + .expect("split_policy must return Some when proxy_redirect is set"); + // The quickstart policy has network_policies with error-level losses on + // isolation_session, but on processcontainer there should be zero error + // losses from the split itself. Warn if there are any error losses so the + // test is informative even when it proceeds. + let error_losses: Vec<_> = result + .loss + .iter() + .filter(|l| l.severity == "error") + .collect(); + if !error_losses.is_empty() { + eprintln!( + "split_policy emitted {} error loss item(s); proceeding to dry-run:\n{:#?}", + error_losses.len(), + error_losses + ); + } + + // Take the mapper's MXC config and inject the required process block. + // The split config does not include a process block (that comes from the + // gateway TOML at runtime); wxc-exec --dry-run requires one. + let mut mxc_config = result.mxc_config.clone(); + mxc_config["process"] = serde_json::json!({ + "commandLine": "cmd /c exit 0", + "cwd": "%TEMP%", + "timeout": 0, + }); + // containerId is also required for processcontainer. + mxc_config["containerId"] = serde_json::json!("split-policy-dryrun"); + + let (code, stdout, stderr) = dry_run(&wxc, &mxc_config); + assert_eq!( + code, + 0, + "split_policy output rejected by --dry-run; \ + this proves the mapper emits valid MXC JSON\n\ + config={}\nstdout={stdout}\nstderr={stderr}", + serde_json::to_string_pretty(&mxc_config).unwrap_or_default() + ); +} + +// ── (b) Enforcement tests — probe-gated ─────────────────────────────────────── +// +// These skip on this box (processcontainer velocity keys not enabled; +// isolation_session backend absent). They PASS where backends are live. + +/// Probe the processcontainer backend. +/// +/// Runs a trivial one-shot (`cmd /c exit 0`, `%TEMP%` grant). Returns +/// `Ok(())` when the backend is live, or `Err(reason)` when it is not (the +/// caller prints SKIP + reason and returns from the test). +fn probe_processcontainer(wxc: &PathBuf) -> Result<(), String> { + // Abort early if the mock env var is set — a stale OPENSHELL_MXC_MOCK_WXC + // would silently turn this "real" run back into a mock run. + if std::env::var("OPENSHELL_MXC_MOCK_WXC") + .map(|v| v == "1") + .unwrap_or(false) + { + return Err( + "OPENSHELL_MXC_MOCK_WXC=1 is set — unset it before running real enforcement tests" + .to_string(), + ); + } + + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "containerId": "probe-pc", + "containment": "processcontainer", + "process": { + "commandLine": "cmd /c exit 0", + "cwd": "%TEMP%", + "timeout": 10, + }, + "filesystem": { + "readwritePaths": ["%TEMP%"], + }, + }); + + let json = serde_json::to_string(&config).unwrap(); + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + + let out = Command::new(wxc) + .arg("--config-base64") + .arg(&b64) + .output() + .map_err(|e| format!("wxc-exec spawn failed: {e}"))?; + + let stdout = String::from_utf8_lossy(&out.stdout).to_lowercase(); + let stderr = String::from_utf8_lossy(&out.stderr).to_lowercase(); + let combined = format!("{stdout} {stderr}"); + + if combined.contains("backend_error") + || combined.contains("e_notimpl") + || combined.contains("velocity") + || combined.contains("not enabled") + { + // Extract the message if possible for a more useful skip reason. + let reason = if let Ok(v) = + serde_json::from_str::(&String::from_utf8_lossy(&out.stdout)) + { + v["error"]["message"] + .as_str() + .unwrap_or("backend_error (E_NOTIMPL)") + .to_string() + } else { + "backend_error (velocity keys not enabled)".to_string() + }; + return Err(reason); + } + + if !out.status.success() { + return Err(format!( + "processcontainer probe returned exit {}: stdout={} stderr={}", + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + )); + } + + Ok(()) +} + +/// Probe the isolation_session backend. +/// +/// Attempts a `provision` phase. Returns `Ok(sandbox_id)` when live, or +/// `Err(reason)` when the backend is unavailable (caller prints SKIP). +fn probe_isolation_session(wxc: &PathBuf) -> Result { + if std::env::var("OPENSHELL_MXC_MOCK_WXC") + .map(|v| v == "1") + .unwrap_or(false) + { + return Err( + "OPENSHELL_MXC_MOCK_WXC=1 is set — unset it before running real enforcement tests" + .to_string(), + ); + } + + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "phase": "provision", + "containment": "isolation_session", + "filesystem": { + "readwritePaths": [], + "readonlyPaths": [], + }, + "experimental": { + "isolation_session": { + "configurationId": "composable", + "provision": {} + } + } + }); + + let json = serde_json::to_string(&config).unwrap(); + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + + let out = Command::new(wxc) + .arg("--config-base64") + .arg(&b64) + .arg("--experimental") + .output() + .map_err(|e| format!("wxc-exec spawn failed: {e}"))?; + + let stdout_raw = String::from_utf8_lossy(&out.stdout).into_owned(); + let stdout_lower = stdout_raw.to_lowercase(); + let stderr_lower = String::from_utf8_lossy(&out.stderr).to_lowercase(); + let combined = format!("{stdout_lower} {stderr_lower}"); + + if combined.contains("backend_unavailable") || combined.contains("0x80040154") { + return Err( + "backend_unavailable: IsoSessionApp.dll absent or OS build < 26300.8553".to_string(), + ); + } + + if !out.status.success() { + return Err(format!( + "isolation_session provision failed (exit {}): {}", + out.status.code().unwrap_or(-1), + stdout_raw + )); + } + + // Parse the sandboxId from {"result":{"sandboxId":"iso:..."}} + let env: serde_json::Value = serde_json::from_str(&stdout_raw) + .map_err(|e| format!("provision envelope parse failed: {e}: {stdout_raw}"))?; + + let sandbox_id = env["result"]["sandboxId"] + .as_str() + .ok_or_else(|| format!("sandboxId missing in provision result: {stdout_raw}"))? + .to_string(); + + Ok(sandbox_id) +} + +/// RAII guard that best-effort deprovisioning on drop — protects the +/// single-session backend against orphaned sessions. +struct DeprovisionGuard<'a> { + wxc: &'a PathBuf, + sandbox_id: Option, +} + +impl<'a> DeprovisionGuard<'a> { + fn new(wxc: &'a PathBuf, sandbox_id: String) -> Self { + Self { + wxc, + sandbox_id: Some(sandbox_id), + } + } + + fn disarm(&mut self) { + self.sandbox_id = None; + } + + fn deprovision_now(&mut self) { + if let Some(id) = self.sandbox_id.take() { + Self::run_deprovision(self.wxc, &id); + } + } + + fn run_deprovision(wxc: &PathBuf, sandbox_id: &str) { + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "phase": "deprovision", + "sandboxId": sandbox_id, + "experimental": { + "isolation_session": { + // Unit variant: must be null, not {} (malformed_request otherwise). + "deprovision": null + } + } + }); + let json = serde_json::to_string(&config).unwrap_or_default(); + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + // Best-effort: ignore errors so the test does not panic in drop. + let _ = Command::new(wxc) + .arg("--config-base64") + .arg(&b64) + .arg("--experimental") + .output(); + } +} + +impl Drop for DeprovisionGuard<'_> { + fn drop(&mut self) { + if let Some(id) = self.sandbox_id.take() { + Self::run_deprovision(self.wxc, &id); + } + } +} + +// ── Processcontainer enforcement tests ─────────────────────────────────────── + +/// Write a file inside the granted temp dir; assert the file appears and the +/// exit code is 0. Requires the processcontainer backend to be live. +#[test] +#[ignore = "requires real wxc-exec"] +fn pc_oneshot_in_policy_write_succeeds() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + if let Err(reason) = probe_processcontainer(&wxc) { + eprintln!("SKIP: processcontainer not live: {reason}"); + return; + } + + let tmpdir = tempfile::tempdir().expect("tempdir"); + let target = tmpdir.path().join("pc-in-policy.txt"); + let target_str = target.to_string_lossy().into_owned(); + let tmpdir_str = tmpdir.path().to_string_lossy().into_owned(); + + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "containerId": "pc-in-policy-write", + "containment": "processcontainer", + "process": { + "commandLine": format!("cmd /c echo hello > \"{target_str}\""), + "cwd": tmpdir_str, + "timeout": 30, + }, + "filesystem": { + "readwritePaths": [tmpdir_str], + }, + "processContainer": { + "leastPrivilege": false, + }, + }); + + let json = serde_json::to_string(&config).unwrap(); + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + + let out = Command::new(&wxc) + .arg("--config-base64") + .arg(&b64) + .output() + .expect("wxc-exec spawn"); + + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + let code = out.status.code().unwrap_or(-1); + + assert_eq!( + code, 0, + "in-policy write should exit 0\nstdout={stdout}\nstderr={stderr}" + ); + assert!( + target.exists(), + "in-policy write: file should exist at {target_str}\nstdout={stdout}\nstderr={stderr}" + ); +} + +/// Write to a path OUTSIDE the granted dir; assert exit non-zero and file absent. +/// This is the genuine OS default-deny proof — the AppContainer blocks the write +/// without requiring any host ACL lockdown. The mock can only fake this. +#[test] +#[ignore = "requires real wxc-exec"] +fn pc_oneshot_out_of_policy_write_denied() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + if let Err(reason) = probe_processcontainer(&wxc) { + eprintln!("SKIP: processcontainer not live: {reason}"); + return; + } + + let granted_dir = tempfile::tempdir().expect("granted tempdir"); + let denied_dir = tempfile::tempdir().expect("denied tempdir"); + let denied_file = denied_dir.path().join("pc-out-of-policy.txt"); + let denied_file_str = denied_file.to_string_lossy().into_owned(); + let granted_str = granted_dir.path().to_string_lossy().into_owned(); + + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "containerId": "pc-out-of-policy-write", + "containment": "processcontainer", + "process": { + "commandLine": format!("cmd /c echo denied > \"{denied_file_str}\""), + "cwd": granted_str, + "timeout": 30, + }, + "filesystem": { + // Only the granted_dir is in policy — denied_dir is NOT granted. + "readwritePaths": [granted_str], + }, + "processContainer": { + "leastPrivilege": false, + }, + }); + + let json = serde_json::to_string(&config).unwrap(); + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + + let out = Command::new(&wxc) + .arg("--config-base64") + .arg(&b64) + .output() + .expect("wxc-exec spawn"); + + let code = out.status.code().unwrap_or(-1); + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + + assert_ne!( + code, 0, + "out-of-policy write should be denied (non-zero exit)\nstdout={stdout}\nstderr={stderr}" + ); + assert!( + !denied_file.exists(), + "out-of-policy file must be absent at {denied_file_str} (OS default-deny proof)\n\ + stdout={stdout}\nstderr={stderr}" + ); +} + +// ── Isolation session enforcement tests ────────────────────────────────────── + +/// Full isolation_session round trip: provision → start → exec → stop → +/// deprovision. `deprovision` runs in a drop-guard even on panic so the +/// single-session backend is never left orphaned. +#[test] +#[ignore = "requires real wxc-exec"] +fn iso_lifecycle_round_trip() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + let sandbox_id = match probe_isolation_session(&wxc) { + Ok(id) => id, + Err(reason) => { + eprintln!("SKIP: isolation_session not live: {reason}"); + return; + } + }; + + // Guard ensures deprovision even on panic. + let mut guard = DeprovisionGuard::new(&wxc, sandbox_id.clone()); + + // start + let start_config = serde_json::json!({ + "version": "0.6.0-alpha", + "phase": "start", + "sandboxId": sandbox_id, + "experimental": { + "isolation_session": { + "start": {} + } + } + }); + let json = serde_json::to_string(&start_config).unwrap(); + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + let out = Command::new(&wxc) + .arg("--config-base64") + .arg(&b64) + .arg("--experimental") + .output() + .expect("start"); + assert!( + out.status.success(), + "start failed: {}", + String::from_utf8_lossy(&out.stdout) + ); + + // exec. timeout is MILLISECONDS; 0 = no timeout. Empirical (test box, + // build 26300.8553, wxc-exec 2026-06-10): a small positive value (30) is + // rejected by RunProcessWithOptionsAsync with "Invalid timeout value" + // (HRESULT 0x80070057). 0 is the documented no-timeout value and matches + // what the driver's exec path sends by default (MxcProcess.timeout = 0). + let exec_config = serde_json::json!({ + "version": "0.6.0-alpha", + "phase": "exec", + "sandboxId": sandbox_id, + "process": { + "commandLine": "cmd /c exit 0", + "cwd": "C:\\Windows\\Temp", + "env": [], + "timeout": 0, + } + }); + let json = serde_json::to_string(&exec_config).unwrap(); + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + let out = Command::new(&wxc) + .arg("--config-base64") + .arg(&b64) + .arg("--experimental") + .output() + .expect("exec"); + assert_eq!( + out.status.code().unwrap_or(-1), + 0, + "exec phase should exit 0: {}", + String::from_utf8_lossy(&out.stdout) + ); + + // stop + let stop_config = serde_json::json!({ + "version": "0.6.0-alpha", + "phase": "stop", + "sandboxId": sandbox_id, + "experimental": { + "isolation_session": { + // Unit variant: must be null, not {} (malformed_request otherwise). + "stop": null + } + } + }); + let json = serde_json::to_string(&stop_config).unwrap(); + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + let out = Command::new(&wxc) + .arg("--config-base64") + .arg(&b64) + .arg("--experimental") + .output() + .expect("stop"); + assert!( + out.status.success(), + "stop failed: {}", + String::from_utf8_lossy(&out.stdout) + ); + + // deprovision (also disarms the guard so Drop does not double-deprovision) + guard.deprovision_now(); + guard.disarm(); +} From 0118a867893e0003694c89ebc3dd2d82faf3585f Mon Sep 17 00:00:00 2001 From: Giedrius Burachas Date: Thu, 11 Jun 2026 18:04:10 -0700 Subject: [PATCH 12/19] test(driver-mxc): add Tier-0 mapper coverage matrix with schema drift guard Three-quadrant, table-driven matrix (38 tests): mappable fields assert exact MXC output; every OpenShell field MXC cannot express asserts a loss item with the expected severity (and seam rejection on error); an empty policy asserts the restrictive default-deny posture for every MXC knob OpenShell does not control. The handled_fields_inventory drift guard serializes a fully-populated policy and compares its YAML keys against the mapper-handled field lists, so a new openshell-policy field fails the suite until consciously mapped, delegated, or reported as loss. Re-exports the policy seam types for integration tests; adds serde_yml, base64, serde_json as dev-dependencies. Signed-off-by: Giedrius Burachas (cherry picked from commit 91807f984a3b16846e35d6ca0d5ec41057aafa3a) Signed-off-by: Jamie King --- crates/openshell-driver-mxc/Cargo.toml | 9 + crates/openshell-driver-mxc/src/lib.rs | 2 + .../tests/policy_mapper_matrix.rs | 1305 +++++++++++++++++ 3 files changed, 1316 insertions(+) create mode 100644 crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs diff --git a/crates/openshell-driver-mxc/Cargo.toml b/crates/openshell-driver-mxc/Cargo.toml index 9486583046..3402af7a3a 100644 --- a/crates/openshell-driver-mxc/Cargo.toml +++ b/crates/openshell-driver-mxc/Cargo.toml @@ -35,6 +35,15 @@ tempfile = "3" openshell-policy = { path = "../openshell-policy" } clap = { workspace = true } anyhow = { workspace = true } +# Needed by the real-wxc integration test (wxc_exec_real.rs) which builds +# --config-base64 payloads without going through the async WxcExecInvoker. +# base64 and serde_json are already [dependencies] but dev-dependency resolution +# is independent; explicit entries make them visible to integration tests. +base64 = { workspace = true } +serde_json = { 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 } [lints] workspace = true diff --git a/crates/openshell-driver-mxc/src/lib.rs b/crates/openshell-driver-mxc/src/lib.rs index 0137e622b8..7347e31dd4 100644 --- a/crates/openshell-driver-mxc/src/lib.rs +++ b/crates/openshell-driver-mxc/src/lib.rs @@ -35,6 +35,8 @@ pub use grpc::ComputeDriverService; // Re-export the embedded mapper API so the windows-only example and integration // test can reach it without making `policy_map` a public module. #[cfg(target_os = "windows")] +pub use policy::{EmbeddedPolicyMapper, MapCtx, MapError, MappedConfig, PolicyMapper}; +#[cfg(target_os = "windows")] pub use policy_map::{ DEFAULT_COMMAND, DEFAULT_CONTAINMENT, DEFAULT_MXC_VERSION, LossItem, MxcMappingOptions, MxcMappingResult, OPEN_SHELL_SUPERSET_GAPS, SplitPolicyResult, build_loss_report, map_to_mxc, diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs new file mode 100644 index 0000000000..597bf56a44 --- /dev/null +++ b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs @@ -0,0 +1,1305 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Tier-0 coverage matrix + schema drift guard for the OpenShell→MXC policy mapper. +//! +//! Three quadrants: +//! A — mappable fields (OpenShell ∩ MXC): assert exact MXC output. +//! B — OpenShell-only features MXC cannot express: assert one loss item per +//! field with the documented severity. +//! C — MXC restrictive defaults ("default deny posture"): empty policy maps +//! to the most-restrictive possible MXC config. +//! +//! Plus: `handled_fields_inventory` — the schema drift guard that fails when +//! `openshell-policy` gains a serialized field the mapper does not account for. + +#![cfg(target_os = "windows")] + +use openshell_core::proto::{ + FilesystemPolicy, GraphqlOperation, L7Allow, L7DenyRule, L7Rule, LandlockPolicy, NetworkBinary, + NetworkEndpoint, NetworkPolicyRule, ProcessPolicy, SandboxPolicy, +}; +use openshell_driver_mxc::{ + EmbeddedPolicyMapper, MapCtx, MapError, MxcMappingOptions, PolicyMapper, map_to_mxc, + split_policy, +}; +use openshell_policy::{serialize_sandbox_policy, validate_sandbox_policy}; +use serde_json::Value; + +// ─── helpers ──────────────────────────────────────────────────────────────── + +fn str_list(v: &Value) -> Vec { + v.as_array() + .map(|a| { + a.iter() + .filter_map(|e| e.as_str().map(str::to_owned)) + .collect() + }) + .unwrap_or_default() +} + +fn default_opts() -> MxcMappingOptions { + MxcMappingOptions::default() // bubblewrap containment +} + +fn bubblewrap_opts() -> MxcMappingOptions { + MxcMappingOptions { + containment: "bubblewrap".to_owned(), + ..Default::default() + } +} + +fn proxy_addr() -> std::net::SocketAddr { + "127.0.0.1:18080".parse().unwrap() +} + +fn pc_split_opts() -> MxcMappingOptions { + MxcMappingOptions { + containment: "processcontainer".to_owned(), + proxy_redirect: Some(proxy_addr()), + ..Default::default() + } +} + +/// Build a minimal policy with one network rule whose endpoints carry a single +/// endpoint set up by the caller. +fn net_policy(key: &str, ep: NetworkEndpoint) -> SandboxPolicy { + let mut p = SandboxPolicy::default(); + p.network_policies.insert( + key.to_owned(), + NetworkPolicyRule { + name: key.to_owned(), + endpoints: vec![ep], + binaries: Vec::new(), + }, + ); + p +} + +/// Assert exactly one loss item whose `path` contains `needle` and whose +/// `severity` equals `want_severity`. +fn assert_single_loss( + loss: &[openshell_driver_mxc::LossItem], + needle: &str, + want_severity: &str, + context: &str, +) { + let matching: Vec<_> = loss + .iter() + .filter(|i| i.path.contains(needle) && i.severity == want_severity) + .collect(); + assert!( + !matching.is_empty(), + "{context}: expected a '{want_severity}' loss with path containing '{needle}', got: {loss:?}" + ); + // There should not be more than one item with a DIFFERENT severity for the same path. + let other_severity: Vec<_> = loss + .iter() + .filter(|i| i.path.contains(needle) && i.severity != want_severity) + .collect(); + assert!( + other_severity.is_empty(), + "{context}: unexpected additional loss item(s) for '{needle}' with wrong severity: {other_severity:?}" + ); +} + +// ─── QUADRANT A: mappable fields, assert exact MXC output ─────────────────── + +/// filesystem.read_write → readwritePaths verbatim, order preserved. +#[test] +fn a_rw_paths_verbatim() { + let policy = SandboxPolicy { + filesystem: Some(FilesystemPolicy { + read_write: vec!["/work".into(), "/tmp".into(), "/data".into()], + ..Default::default() + }), + ..Default::default() + }; + let r = map_to_mxc(&policy, &default_opts()); + assert_eq!( + str_list(&r.config["filesystem"]["readwritePaths"]), + vec!["/work", "/tmp", "/data"], + "readwritePaths must be verbatim, in order" + ); +} + +/// filesystem.read_only → readonlyPaths verbatim, order preserved. +#[test] +fn a_ro_paths_verbatim() { + let policy = SandboxPolicy { + filesystem: Some(FilesystemPolicy { + read_only: vec!["/usr".into(), "/lib".into()], + ..Default::default() + }), + ..Default::default() + }; + let r = map_to_mxc(&policy, &default_opts()); + assert_eq!( + str_list(&r.config["filesystem"]["readonlyPaths"]), + vec!["/usr", "/lib"], + "readonlyPaths must be verbatim, in order" + ); +} + +/// include_workdir=true + opts.cwd set → cwd appended (unique) to readwritePaths. +#[test] +fn a_include_workdir_with_cwd_appended_unique() { + let policy = SandboxPolicy { + filesystem: Some(FilesystemPolicy { + include_workdir: true, + read_write: vec!["/work".into()], + ..Default::default() + }), + ..Default::default() + }; + let opts = MxcMappingOptions { + cwd: Some("/work".to_owned()), // already present — must not duplicate + ..default_opts() + }; + let r = map_to_mxc(&policy, &opts); + let rw = str_list(&r.config["filesystem"]["readwritePaths"]); + assert!( + rw.contains(&"/work".to_owned()), + "cwd must appear in readwritePaths" + ); + let count = rw.iter().filter(|p| p.as_str() == "/work").count(); + assert_eq!(count, 1, "cwd must not be duplicated"); + + // Also test where cwd is new. + let opts2 = MxcMappingOptions { + cwd: Some("/newcwd".to_owned()), + ..default_opts() + }; + let policy2 = SandboxPolicy { + filesystem: Some(FilesystemPolicy { + include_workdir: true, + read_write: vec!["/work".into()], + ..Default::default() + }), + ..Default::default() + }; + let r2 = map_to_mxc(&policy2, &opts2); + let rw2 = str_list(&r2.config["filesystem"]["readwritePaths"]); + assert!( + rw2.contains(&"/newcwd".to_owned()), + "new cwd must be appended to readwritePaths" + ); +} + +/// include_workdir=true, no cwd → an "info" loss item, no extra path added. +#[test] +fn a_include_workdir_no_cwd_emits_info_loss() { + let policy = SandboxPolicy { + filesystem: Some(FilesystemPolicy { + include_workdir: true, + read_write: vec!["/work".into()], + ..Default::default() + }), + ..Default::default() + }; + let r = map_to_mxc(&policy, &default_opts()); // cwd is None + let has_info = r + .loss + .iter() + .any(|i| i.severity == "info" && i.path.contains("include_workdir")); + assert!( + has_info, + "expected info loss for include_workdir without cwd; got: {:?}", + r.loss + ); + // The path list must not have grown beyond the source list. + let rw = str_list(&r.config["filesystem"]["readwritePaths"]); + assert_eq!(rw, vec!["/work"]); +} + +/// Plain endpoint host → appears in allowedHosts. +#[test] +fn a_plain_host_in_allowed_hosts() { + let policy = net_policy( + "api", + NetworkEndpoint { + host: "api.example.com".into(), + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + let hosts = str_list(&r.config["network"]["allowedHosts"]); + assert!( + hosts.contains(&"api.example.com".to_owned()), + "host must appear in allowedHosts; got: {hosts:?}" + ); +} + +/// endpoint.allowed_ips → each IP appended to allowedHosts + a "warning" loss. +#[test] +fn a_allowed_ips_appended_with_warning() { + let policy = net_policy( + "api", + NetworkEndpoint { + host: "db.internal".into(), + allowed_ips: vec!["10.0.0.1".into(), "10.0.0.2".into()], + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + let hosts = str_list(&r.config["network"]["allowedHosts"]); + assert!( + hosts.contains(&"10.0.0.1".to_owned()), + "IP 10.0.0.1 must be in allowedHosts" + ); + assert!( + hosts.contains(&"10.0.0.2".to_owned()), + "IP 10.0.0.2 must be in allowedHosts" + ); + let warnings: Vec<_> = r + .loss + .iter() + .filter(|i| i.severity == "warning" && i.path.contains("allowed_ips")) + .collect(); + assert!( + !warnings.is_empty(), + "expected warning loss for allowed_ips; got: {:?}", + r.loss + ); +} + +/// Duplicate hosts across rules → deduplicated in allowedHosts. +#[test] +fn a_duplicate_hosts_deduplicated() { + let mut policy = SandboxPolicy::default(); + let ep_a = NetworkEndpoint { + host: "shared.example.com".into(), + ..Default::default() + }; + let ep_b = NetworkEndpoint { + host: "shared.example.com".into(), + ..Default::default() + }; + policy.network_policies.insert( + "rule_a".to_owned(), + NetworkPolicyRule { + name: "rule_a".to_owned(), + endpoints: vec![ep_a], + binaries: Vec::new(), + }, + ); + policy.network_policies.insert( + "rule_b".to_owned(), + NetworkPolicyRule { + name: "rule_b".to_owned(), + endpoints: vec![ep_b], + binaries: Vec::new(), + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + let hosts = str_list(&r.config["network"]["allowedHosts"]); + let count = hosts + .iter() + .filter(|h| h.as_str() == "shared.example.com") + .count(); + assert_eq!( + count, 1, + "duplicate hosts must be deduplicated; got: {hosts:?}" + ); +} + +/// Determinism: a policy with 3+ network rules (unordered map) maps twice → identical config JSON. +#[test] +fn a_deterministic_with_multiple_rules() { + let mut policy = SandboxPolicy::default(); + for (key, host) in &[ + ("rule_z", "z.example.com"), + ("rule_a", "a.example.com"), + ("rule_m", "m.example.com"), + ("rule_b", "b.example.com"), + ] { + policy.network_policies.insert( + key.to_string(), + NetworkPolicyRule { + name: key.to_string(), + endpoints: vec![NetworkEndpoint { + host: host.to_string(), + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + } + let r1 = map_to_mxc(&policy, &bubblewrap_opts()); + let r2 = map_to_mxc(&policy, &bubblewrap_opts()); + assert_eq!( + r1.config, r2.config, + "map_to_mxc must be deterministic across two calls" + ); +} + +/// split path: proxy_policy.network_policies == source's, proxy_policy.version preserved. +#[test] +fn a_split_network_verbatim_and_version_preserved() { + let mut policy = SandboxPolicy { + version: 42, + ..Default::default() + }; + policy.network_policies.insert( + "api".to_owned(), + NetworkPolicyRule { + name: "api".to_owned(), + endpoints: vec![NetworkEndpoint { + host: "api.example.com".into(), + ports: vec![443], + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + let result = split_policy(&policy, &pc_split_opts()).expect("split must return Some"); + assert_eq!( + result.proxy_policy.network_policies, policy.network_policies, + "split: proxy_policy.network_policies must equal source" + ); + assert_eq!( + result.proxy_policy.version, 42, + "split: proxy_policy.version must equal source" + ); +} + +/// split path: mxc_config["network"]["proxy"]["localhost"] == port (new schema). +#[test] +fn a_split_proxy_localhost_port() { + let policy = SandboxPolicy::default(); + let result = split_policy(&policy, &pc_split_opts()).expect("split must return Some"); + assert_eq!( + result.mxc_config["network"]["proxy"]["localhost"], 18080, + "split must emit network.proxy.localhost == port" + ); + // allowedHosts stays empty on the split path. + assert!( + str_list(&result.mxc_config["network"]["allowedHosts"]).is_empty(), + "split path must have empty allowedHosts" + ); +} + +// ─── QUADRANT B: OpenShell features MXC cannot express ────────────────────── +// +// For each field: build a minimal policy setting only that field (plus the +// minimum needed to reach the code path), map with default bubblewrap +// containment, assert exactly one loss item with the documented path fragment +// and severity. + +/// endpoint.ports → "error" (path contains ".port"). +#[test] +fn b_ports_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + ports: vec![443], + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss(&r.loss, ".port", "error", "endpoint.ports"); +} + +/// endpoint.protocol → "error". +#[test] +fn b_protocol_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + protocol: "rest".into(), + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss(&r.loss, ".protocol", "error", "endpoint.protocol"); +} + +/// endpoint.tls = "skip" → "warning". +#[test] +fn b_tls_skip_warning() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + tls: "skip".into(), + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss(&r.loss, ".tls", "warning", "endpoint.tls=skip"); +} + +/// endpoint.tls = "full" (any non-skip) → "error". +#[test] +fn b_tls_non_skip_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + tls: "terminate".into(), + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss(&r.loss, ".tls", "error", "endpoint.tls=terminate"); +} + +/// endpoint.enforcement = "audit" → "error". +#[test] +fn b_enforcement_audit_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + enforcement: "audit".into(), + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss( + &r.loss, + ".enforcement", + "error", + "endpoint.enforcement=audit", + ); +} + +/// endpoint.enforcement = "enforce" (non-audit) → "warning". +#[test] +fn b_enforcement_non_audit_warning() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + enforcement: "enforce".into(), + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss( + &r.loss, + ".enforcement", + "warning", + "endpoint.enforcement=enforce", + ); +} + +/// endpoint.access → "error". +#[test] +fn b_access_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + access: "read-only".into(), + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss(&r.loss, ".access", "error", "endpoint.access"); +} + +/// endpoint.rules (one allow rule) → "error". +#[test] +fn b_rules_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + rules: vec![L7Rule { + allow: Some(L7Allow { + method: "GET".into(), + path: "/api".into(), + ..Default::default() + }), + }], + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss(&r.loss, ".rules", "error", "endpoint.rules"); +} + +/// endpoint.deny_rules → "error". +#[test] +fn b_deny_rules_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + deny_rules: vec![L7DenyRule { + method: "POST".into(), + path: "/admin".into(), + ..Default::default() + }], + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss(&r.loss, ".deny_rules", "error", "endpoint.deny_rules"); +} + +/// endpoint.allow_encoded_slash=true → "error". +#[test] +fn b_allow_encoded_slash_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + allow_encoded_slash: true, + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss( + &r.loss, + ".allow_encoded_slash", + "error", + "allow_encoded_slash", + ); +} + +/// endpoint.websocket_credential_rewrite=true → "error". +#[test] +fn b_websocket_credential_rewrite_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + websocket_credential_rewrite: true, + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss( + &r.loss, + ".websocket_credential_rewrite", + "error", + "websocket_credential_rewrite", + ); +} + +/// endpoint.request_body_credential_rewrite=true → "error". +#[test] +fn b_request_body_credential_rewrite_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + request_body_credential_rewrite: true, + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss( + &r.loss, + ".request_body_credential_rewrite", + "error", + "request_body_credential_rewrite", + ); +} + +/// endpoint.persisted_queries → "error". +#[test] +fn b_persisted_queries_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + persisted_queries: "deny".into(), + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss(&r.loss, ".persisted_queries", "error", "persisted_queries"); +} + +/// endpoint.graphql_persisted_queries → "error". +#[test] +fn b_graphql_persisted_queries_error() { + let mut ep = NetworkEndpoint { + host: "api.example.com".into(), + ..Default::default() + }; + ep.graphql_persisted_queries.insert( + "abc".to_owned(), + GraphqlOperation { + operation_type: "query".into(), + ..Default::default() + }, + ); + let policy = net_policy("r", ep); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss( + &r.loss, + ".graphql_persisted_queries", + "error", + "graphql_persisted_queries", + ); +} + +/// endpoint.graphql_max_body_bytes > 0 → "error". +#[test] +fn b_graphql_max_body_bytes_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + graphql_max_body_bytes: 65536, + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss( + &r.loss, + ".graphql_max_body_bytes", + "error", + "graphql_max_body_bytes", + ); +} + +/// Wildcard host ("*.example.com") with allow_wildcards=false → "error", host NOT in allowedHosts. +#[test] +fn b_wildcard_host_deny_wildcards_error_host_absent() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "*.example.com".into(), + ..Default::default() + }, + ); + let opts = MxcMappingOptions { + allow_wildcards: false, + containment: "bubblewrap".to_owned(), + ..Default::default() + }; + let r = map_to_mxc(&policy, &opts); + // Must have an "error" loss for the wildcard host. + let err_loss: Vec<_> = r + .loss + .iter() + .filter(|i| i.severity == "error" && i.path.contains(".host")) + .collect(); + assert!( + !err_loss.is_empty(), + "expected error loss for wildcard host; got: {:?}", + r.loss + ); + // Host must NOT be in allowedHosts when allow_wildcards=false. + let hosts = str_list(&r.config["network"]["allowedHosts"]); + assert!( + !hosts.contains(&"*.example.com".to_owned()), + "wildcard host must be absent from allowedHosts when allow_wildcards=false; got: {hosts:?}" + ); +} + +/// Wildcard host with allow_wildcards=true → "error" (semantics warning), host IS in allowedHosts. +#[test] +fn b_wildcard_host_allow_wildcards_error_host_present() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "*.example.com".into(), + ..Default::default() + }, + ); + let opts = MxcMappingOptions { + allow_wildcards: true, + containment: "bubblewrap".to_owned(), + ..Default::default() + }; + let r = map_to_mxc(&policy, &opts); + // Still an "error" loss (MXC semantics warning), even though we emitted the host. + let err_loss: Vec<_> = r + .loss + .iter() + .filter(|i| i.severity == "error" && i.path.contains(".host")) + .collect(); + assert!( + !err_loss.is_empty(), + "expected error loss for wildcard host even with allow_wildcards=true; got: {:?}", + r.loss + ); + // Host IS in allowedHosts when allow_wildcards=true. + let hosts = str_list(&r.config["network"]["allowedHosts"]); + assert!( + hosts.contains(&"*.example.com".to_owned()), + "wildcard host must appear in allowedHosts when allow_wildcards=true; got: {hosts:?}" + ); +} + +/// rule.binaries non-empty → "error" per binary. +#[test] +fn b_binaries_error_per_binary() { + let mut policy = SandboxPolicy::default(); + policy.network_policies.insert( + "r".to_owned(), + NetworkPolicyRule { + name: "r".to_owned(), + endpoints: vec![NetworkEndpoint { + host: "api.example.com".into(), + ..Default::default() + }], + binaries: vec![ + NetworkBinary { + path: "/usr/bin/curl".into(), + ..Default::default() + }, + NetworkBinary { + path: "/usr/bin/wget".into(), + ..Default::default() + }, + ], + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + let binary_errors: Vec<_> = r + .loss + .iter() + .filter(|i| i.severity == "error" && i.path.contains("binaries[")) + .collect(); + assert_eq!( + binary_errors.len(), + 2, + "expected one error loss per binary; got: {:?}", + r.loss + ); +} + +/// rule with empty endpoints → "error". +#[test] +fn b_empty_endpoints_error() { + let mut policy = SandboxPolicy::default(); + policy.network_policies.insert( + "r".to_owned(), + NetworkPolicyRule { + name: "r".to_owned(), + endpoints: Vec::new(), // empty + binaries: Vec::new(), + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + let endpoint_errors: Vec<_> = r + .loss + .iter() + .filter(|i| i.severity == "error" && i.path.contains(".endpoints")) + .collect(); + assert!( + !endpoint_errors.is_empty(), + "expected error loss for empty endpoints; got: {:?}", + r.loss + ); +} + +/// endpoint with empty host → "error". +#[test] +fn b_empty_host_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: String::new(), // empty + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + let host_errors: Vec<_> = r + .loss + .iter() + .filter(|i| i.severity == "error" && i.path.contains(".host")) + .collect(); + assert!( + !host_errors.is_empty(), + "expected error loss for empty host; got: {:?}", + r.loss + ); +} + +/// rule with empty binaries → "error". +#[test] +fn b_empty_binaries_error() { + let mut policy = SandboxPolicy::default(); + policy.network_policies.insert( + "r".to_owned(), + NetworkPolicyRule { + name: "r".to_owned(), + endpoints: vec![NetworkEndpoint { + host: "api.example.com".into(), + ..Default::default() + }], + binaries: Vec::new(), // empty + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + let bin_errors: Vec<_> = r + .loss + .iter() + .filter(|i| i.severity == "error" && i.path.contains(".binaries")) + .collect(); + assert!( + !bin_errors.is_empty(), + "expected error loss for empty binaries; got: {:?}", + r.loss + ); +} + +/// policy.landlock = Some(default) → "warning". +#[test] +fn b_landlock_warning() { + let policy = SandboxPolicy { + landlock: Some(LandlockPolicy { + compatibility: "best_effort".into(), + }), + ..Default::default() + }; + let r = map_to_mxc(&policy, &default_opts()); + assert_single_loss(&r.loss, "landlock", "warning", "landlock"); +} + +/// process.run_as_user non-empty → "warning". +#[test] +fn b_run_as_user_warning() { + let policy = SandboxPolicy { + process: Some(ProcessPolicy { + run_as_user: "sandbox".into(), + run_as_group: String::new(), + }), + ..Default::default() + }; + let r = map_to_mxc(&policy, &default_opts()); + assert_single_loss(&r.loss, "run_as_user", "warning", "process.run_as_user"); +} + +/// process.run_as_group non-empty → "warning". +#[test] +fn b_run_as_group_warning() { + let policy = SandboxPolicy { + process: Some(ProcessPolicy { + run_as_user: String::new(), + run_as_group: "sandboxers".into(), + }), + ..Default::default() + }; + let r = map_to_mxc(&policy, &default_opts()); + assert_single_loss(&r.loss, "run_as_group", "warning", "process.run_as_group"); +} + +/// Seam-level: EmbeddedPolicyMapper.map over a policy with one error-class field +/// (a port) via MapCtx{egress: None} returns Err(MapError::Unsupported(_)). +#[test] +fn b_seam_returns_unsupported_on_error_field() { + let mapper = EmbeddedPolicyMapper; + // isolation_session containment + network policy → error loss from add_backend_specific_config. + let mut policy = SandboxPolicy { + filesystem: Some(FilesystemPolicy { + read_write: vec!["C:/work".into()], + ..Default::default() + }), + ..Default::default() + }; + policy.network_policies.insert( + "api".to_owned(), + NetworkPolicyRule { + name: "api".to_owned(), + endpoints: vec![NetworkEndpoint { + host: "api.example.com".into(), + ports: vec![443], + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + let ctx = MapCtx { + sandbox_id: "sb-test".into(), + share_dir: None, + egress: None, // coarse path → isolation_session → network policy errors + }; + let err = mapper.map(Some(&policy), &ctx).unwrap_err(); + assert!( + matches!(err, MapError::Unsupported(_)), + "seam must return MapError::Unsupported for error-class losses; got: {err:?}" + ); +} + +// ─── QUADRANT C: restrictive defaults ("default deny posture") ─────────────── + +/// Empty SandboxPolicy (all None/empty) with default options produces the most +/// restrictive possible MXC config. +#[test] +fn c_empty_policy_default_deny_posture() { + let policy = SandboxPolicy::default(); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + let cfg = &r.config; + + // Network: default deny, no allowed/blocked hosts. + assert_eq!( + cfg["network"]["defaultPolicy"], "block", + "defaultPolicy must be 'block' on empty policy" + ); + assert!( + str_list(&cfg["network"]["allowedHosts"]).is_empty(), + "allowedHosts must be [] on empty policy" + ); + assert!( + str_list(&cfg["network"]["blockedHosts"]).is_empty(), + "blockedHosts must be [] on empty policy" + ); + + // UI: fully locked down. + assert_eq!(cfg["ui"]["disable"], true, "ui.disable must be true"); + assert_eq!( + cfg["ui"]["clipboard"], "none", + "ui.clipboard must be 'none'" + ); + assert_eq!(cfg["ui"]["injection"], false, "ui.injection must be false"); + + // Lifecycle: destroyOnExit + no policy preservation. + assert_eq!( + cfg["lifecycle"]["destroyOnExit"], true, + "lifecycle.destroyOnExit must be true" + ); + assert_eq!( + cfg["lifecycle"]["preservePolicy"], false, + "lifecycle.preservePolicy must be false" + ); + + // Filesystem: all lists empty, deniedPaths empty. + assert!( + str_list(&cfg["filesystem"]["readwritePaths"]).is_empty(), + "readwritePaths must be [] on empty policy" + ); + assert!( + str_list(&cfg["filesystem"]["readonlyPaths"]).is_empty(), + "readonlyPaths must be [] on empty policy" + ); + assert!( + str_list(&cfg["filesystem"]["deniedPaths"]).is_empty(), + "deniedPaths must be [] on empty policy" + ); + + // No processContainer key when no hosts are granted. + assert!( + cfg.get("processContainer").is_none(), + "processContainer must be absent when no hosts are mapped" + ); + + // No enforcementMode key when allowedHosts is empty. + assert!( + cfg["network"].get("enforcementMode").is_none(), + "enforcementMode must be absent when allowedHosts is empty" + ); +} + +/// Split path with network rules present: allowedHosts stays empty. +#[test] +fn c_split_empty_allowed_hosts_with_network_rules() { + let mut policy = SandboxPolicy::default(); + policy.network_policies.insert( + "api".to_owned(), + NetworkPolicyRule { + name: "api".to_owned(), + endpoints: vec![NetworkEndpoint { + host: "api.example.com".into(), + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + let result = split_policy(&policy, &pc_split_opts()).expect("split must return Some"); + assert!( + str_list(&result.mxc_config["network"]["allowedHosts"]).is_empty(), + "split path allowedHosts must be empty even with network rules; got: {:?}", + result.mxc_config["network"]["allowedHosts"] + ); + // But proxy redirect is present. + assert_eq!( + result.mxc_config["network"]["proxy"]["localhost"], 18080, + "split must emit network.proxy.localhost" + ); +} + +// ─── DRIFT GUARD ───────────────────────────────────────────────────────────── +// +// Serialize policies via openshell_policy::serialize_sandbox_policy, collect +// YAML keys, compare against HANDLED_* const slices. Fails when a new field +// is added to the schema without updating the mapper. + +/// Top-level fields of the YAML policy schema that the mapper handles today. +/// Derived from what map.rs and build_split_mxc_config actually read. +/// +/// "version" — emitted into mxc_config["version"] (not a loss) +/// "filesystem_policy" — mapped via map_filesystem +/// "landlock" — loss item emitted in add_static_policy_loss +/// "process" — loss items for run_as_user / run_as_group +/// "network_policies" — mapped via map_network / delegated in split +const HANDLED_TOPLEVEL: &[&str] = &[ + "version", + "filesystem_policy", + "landlock", + "process", + "network_policies", +]; + +/// Per-rule keys under each network_policies entry that the mapper handles. +const HANDLED_RULE_KEYS: &[&str] = &["name", "endpoints", "binaries"]; + +/// Per-endpoint keys that the mapper accounts for (mapping or loss item). +/// +/// "host" — mapped to allowedHosts (or loss if wildcard/empty) +/// "port" — normalized to ports at parse; covered by ports loss +/// "ports" — error loss +/// "protocol" — error loss +/// "tls" — warning (skip) or error loss +/// "enforcement" — error (audit) or warning (other) loss +/// "access" — error loss +/// "rules" — error loss +/// "allowed_ips" — appended to allowedHosts + warning loss +/// "deny_rules" — error loss +/// "allow_encoded_slash" — error loss +/// "websocket_credential_rewrite" — error loss +/// "request_body_credential_rewrite" — error loss +/// "persisted_queries" — error loss +/// "graphql_persisted_queries" — error loss +/// "graphql_max_body_bytes" — error loss +/// "path" — not currently read by the mapper (no loss emitted); +/// included here so the drift guard does not trip on +/// existing schema fields the mapper silently ignores. +/// If the mapper needs to enforce path-scoped routing, +/// remove this entry and add an explicit loss item. +const HANDLED_ENDPOINT_KEYS: &[&str] = &[ + "host", + "port", + "ports", + "protocol", + "tls", + "enforcement", + "access", + "rules", + "allowed_ips", + "deny_rules", + "allow_encoded_slash", + "websocket_credential_rewrite", + "request_body_credential_rewrite", + "persisted_queries", + "graphql_persisted_queries", + "graphql_max_body_bytes", + "path", +]; + +#[test] +fn handled_fields_inventory() { + use std::collections::BTreeSet; + + // ── (1) Top-level keys: serialize a SandboxPolicy with every section + // present-but-minimal, then collect YAML keys. ────────────────────────── + let full_toplevel_policy = SandboxPolicy { + version: 1, + filesystem: Some(FilesystemPolicy { + include_workdir: false, + read_only: vec!["/usr".into()], + read_write: vec!["/work".into()], + }), + landlock: Some(LandlockPolicy { + compatibility: "best_effort".into(), + }), + process: Some(ProcessPolicy { + run_as_user: "sandbox".into(), + run_as_group: "sandbox".into(), + }), + network_policies: { + let mut m = std::collections::HashMap::new(); + m.insert( + "rule".to_owned(), + NetworkPolicyRule { + name: "rule".to_owned(), + endpoints: vec![NetworkEndpoint { + host: "api.example.com".into(), + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + m + }, + }; + + // Validate so the test itself doesn't carry a bad policy. + validate_sandbox_policy(&full_toplevel_policy).expect("test policy must be valid"); + + let yaml_toplevel = + serialize_sandbox_policy(&full_toplevel_policy).expect("serialize full_toplevel_policy"); + let top_value: Value = + serde_yml::from_str::(&yaml_toplevel).expect("re-parse as JSON value"); + let top_obj = top_value + .as_object() + .expect("top-level must be a JSON object"); + let observed_toplevel: BTreeSet<&str> = top_obj.keys().map(String::as_str).collect(); + let expected_toplevel: BTreeSet<&str> = HANDLED_TOPLEVEL.iter().copied().collect(); + + let unhandled_top: Vec<&&str> = observed_toplevel + .iter() + .filter(|k| !expected_toplevel.contains(**k)) + .collect(); + assert!( + unhandled_top.is_empty(), + "openshell-policy gained top-level field(s) {:?} that the policy mapper does not handle \ + — map it, delegate it, or add a loss item, then update HANDLED_TOPLEVEL in this test.", + unhandled_top + ); + + let missing_top: Vec<&&str> = expected_toplevel + .iter() + .filter(|k| !observed_toplevel.contains(**k)) + .collect(); + assert!( + missing_top.is_empty(), + "HANDLED_TOPLEVEL lists field(s) {:?} that are no longer emitted by \ + serialize_sandbox_policy — remove them from HANDLED_TOPLEVEL.", + missing_top + ); + + // ── (2) Per-rule and per-endpoint keys: serialize a policy with one fully- + // populated NetworkPolicyRule / NetworkEndpoint. ───────────────────────── + // + // Note: `port` (scalar) and `ports` (array) are mutually exclusive in the + // serialized form — single port emits `port`; multiple ports emit `ports`. + // To cover both variants we use TWO endpoints: the first with multi-port + // (triggers `ports` key), the second with single-port (triggers `port`). + // The drift guard collects the UNION of all endpoint keys observed. + let mut full_ep = NetworkEndpoint { + host: "api.example.com".into(), + path: "/graphql".into(), + // Two ports → serializes as `ports: [80, 443]` (array form). + ports: vec![80, 443], + protocol: "graphql".into(), + tls: "skip".into(), + enforcement: "enforce".into(), + access: "full".into(), + allowed_ips: vec!["10.0.0.1".into()], + allow_encoded_slash: true, + websocket_credential_rewrite: true, + request_body_credential_rewrite: true, + persisted_queries: "deny".into(), + graphql_max_body_bytes: 65536, + rules: vec![L7Rule { + allow: Some(L7Allow { + method: "GET".into(), + path: "/foo".into(), + ..Default::default() + }), + }], + deny_rules: vec![L7DenyRule { + method: "POST".into(), + path: "/bar".into(), + ..Default::default() + }], + ..Default::default() + }; + full_ep.graphql_persisted_queries.insert( + "abc".to_owned(), + GraphqlOperation { + operation_type: "query".into(), + ..Default::default() + }, + ); + // Second endpoint: single port → serializes as `port: 443` (scalar form). + let single_port_ep = NetworkEndpoint { + host: "other.example.com".into(), + ports: vec![443], + ..Default::default() + }; + + let full_rule_policy = SandboxPolicy { + version: 1, + network_policies: { + let mut m = std::collections::HashMap::new(); + m.insert( + "rule".to_owned(), + NetworkPolicyRule { + name: "rule".to_owned(), + endpoints: vec![full_ep, single_port_ep], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".into(), + ..Default::default() + }], + }, + ); + m + }, + ..Default::default() + }; + + let yaml_rule = + serialize_sandbox_policy(&full_rule_policy).expect("serialize full_rule_policy"); + let rule_value: Value = + serde_yml::from_str::(&yaml_rule).expect("re-parse rule policy as JSON value"); + + // Collect per-rule keys. + let network_policies_obj = rule_value["network_policies"] + .as_object() + .expect("network_policies must be an object"); + let first_rule = network_policies_obj + .values() + .next() + .expect("at least one rule") + .as_object() + .expect("rule must be an object"); + let observed_rule_keys: BTreeSet<&str> = first_rule.keys().map(String::as_str).collect(); + let expected_rule_keys: BTreeSet<&str> = HANDLED_RULE_KEYS.iter().copied().collect(); + + let unhandled_rule: Vec<&&str> = observed_rule_keys + .iter() + .filter(|k| !expected_rule_keys.contains(**k)) + .collect(); + assert!( + unhandled_rule.is_empty(), + "openshell-policy gained network rule field(s) {:?} that the policy mapper does not handle \ + — map it, delegate it, or add a loss item, then update HANDLED_RULE_KEYS in this test.", + unhandled_rule + ); + + let missing_rule: Vec<&&str> = expected_rule_keys + .iter() + .filter(|k| !observed_rule_keys.contains(**k)) + .collect(); + assert!( + missing_rule.is_empty(), + "HANDLED_RULE_KEYS lists field(s) {:?} that are no longer emitted — remove them.", + missing_rule + ); + + // Collect per-endpoint keys: union across ALL endpoints so that mutually- + // exclusive fields like `port` (single-port form) and `ports` (multi-port + // form) are both captured. + let endpoints_arr = first_rule["endpoints"] + .as_array() + .expect("endpoints must be an array"); + let observed_ep_keys: BTreeSet<&str> = endpoints_arr + .iter() + .filter_map(|ep| ep.as_object()) + .flat_map(|obj| obj.keys().map(String::as_str)) + .collect(); + let expected_ep_keys: BTreeSet<&str> = HANDLED_ENDPOINT_KEYS.iter().copied().collect(); + + let unhandled_ep: Vec<&&str> = observed_ep_keys + .iter() + .filter(|k| !expected_ep_keys.contains(**k)) + .collect(); + assert!( + unhandled_ep.is_empty(), + "openshell-policy gained endpoint field(s) {:?} that the policy mapper does not handle \ + — map it, delegate it, or add a loss item, then update HANDLED_ENDPOINT_KEYS in this test.", + unhandled_ep + ); + + let missing_ep: Vec<&&str> = expected_ep_keys + .iter() + .filter(|k| !observed_ep_keys.contains(**k)) + .collect(); + assert!( + missing_ep.is_empty(), + "HANDLED_ENDPOINT_KEYS lists field(s) {:?} that are no longer emitted — remove them.", + missing_ep + ); +} From 8aec2a8e0c24ef457fcb0b3d67fbb775f9c5a41b Mon Sep 17 00:00:00 2001 From: Jamie King Date: Wed, 24 Jun 2026 08:23:56 -0600 Subject: [PATCH 13/19] feat(driver-mxc): inject agent_env into sandbox process.env Add MxcComputeConfig.agent_env: each entry is either KEY=VALUE (verbatim) or a bare KEY resolved from the gateway host environment at launch, keeping secrets (e.g. inference API keys) out of the config file. Wire it into the agent process so gateway-launched agents can authenticate to cloud endpoints (process.env was previously hardcoded empty). Unit-tested via resolve_agent_env_passthrough_and_host_lookup. Also add a gateway-driven cloud-inference (T1) test harness: mxc-inference.toml (agent_env + curl agent), inference.yaml policy, and run-inference-test.ps1 which starts the gateway, creates an isolation_session sandbox, runs an authenticated Nemotron call, and bundles redacted results. Documented agent_env in mxc-gateway.toml. Validated end-to-end on the test box (chat HTTP 200 + completion via the gateway). (cherry picked from commit 94d9e827b8b77e0af9dc654943ea8e7d8400cc9f) Signed-off-by: Jamie King --- .../examples/mxc-gateway.toml | 46 ++++++++++++---- crates/openshell-driver-mxc/src/driver.rs | 53 ++++++++++++++++++- 2 files changed, 89 insertions(+), 10 deletions(-) diff --git a/crates/openshell-driver-mxc/examples/mxc-gateway.toml b/crates/openshell-driver-mxc/examples/mxc-gateway.toml index 3400062a46..fd266b197b 100644 --- a/crates/openshell-driver-mxc/examples/mxc-gateway.toml +++ b/crates/openshell-driver-mxc/examples/mxc-gateway.toml @@ -10,18 +10,24 @@ # Keep `share_dir`, `agent_cwd`, the agent_command target path, and demo.yaml's # `filesystem_policy.read_write` entry IDENTICAL, or the positive proof will not # line up. +# +# The recommended agent is the packaged `mxc-demo-agent.exe` test application: it +# runs BOTH the positive (in-policy) write and the negative (out-of-policy, +# expected-denied) write in a single run and exits 0 only when policy is enforced +# correctly. The runbook copies it into `share_dir` so it is mapped into the +# sandbox and launchable from inside. [openshell.drivers.mxc] # Path to wxc-exec.exe — REQUIRED for live runs on the demo box. Leave commented # for mock-mode smoke tests (set OPENSHELL_MXC_MOCK_WXC=1 instead). -# wxc_exec_path = "C:\\mxc\\wxc-exec.exe" +wxc_exec_path = "C:\\mxc\\wxc-exec.exe" # Backend to target: # "isolation_session" (default) - persistent session; grant-only filesystem # policy (NO default-deny: a write to an ungranted path may still succeed). # "process_container" - one-shot AppContainer; genuinely default-deny (a write # to any ungranted path is denied by the OS). No persistent session. -# backend = "process_container" +backend = "process_container" # process_container only: request a Less-Privileged AppContainer (stricter). # pc_least_privilege = false @@ -37,15 +43,37 @@ share_dir = "C:/work/openshell-mxc-demo" # Working directory for the agent inside the sandbox (defaults to share_dir). agent_cwd = "C:/work/openshell-mxc-demo" -# The agent the driver execs (exec-in-driver). POSITIVE demo: writes hello.txt -# INSIDE the granted share. Swap target to an out-of-policy path (e.g. -# C:/Windows/Temp/hello.txt) to drive the NEGATIVE proof. +# Environment variables injected into the agent process inside the sandbox +# (MXC process.env). Each entry is either: +# "KEY=VALUE" - passed through verbatim, or +# "KEY" - resolved from the GATEWAY HOST environment at launch. +# Use the bare-"KEY" form for secrets (e.g. inference API keys): set the value +# on the gateway host (e.g. $env:NV_API_KEY) and reference it by name so it +# never lands in this file. Unset host vars are skipped with a warning. +# agent_env = [ +# "NV_API_KEY", # resolved from the gateway host env +# "OPENAI_BASE_URL=https://integrate.api.nvidia.com/v1", +# ] + +# The agent the driver execs (exec-in-driver). Runs mxc-demo-agent.exe from the +# mapped share folder; the test app performs the positive (in-policy) write + +# read-back AND the negative (out-of-policy) write, then writes its verdict to +# `mxc-demo-agent-result.txt` in the share (host-visible). Args are optional and +# default to the demo paths; passed explicitly here so they track this config. agent_command = [ - "powershell", - "-NoProfile", - "-Command", - "Set-Content -Path 'C:/work/openshell-mxc-demo/hello.txt' -Value 'hello from mxc'", + "C:/work/openshell-mxc-demo/mxc-demo-agent.exe", + "C:/work/openshell-mxc-demo", + "C:/Windows/Temp/openshell-mxc-out-of-policy.txt", ] +# Fallback (no test app): one-liner that only drives the POSITIVE proof. Swap the +# target to an out-of-policy path to manually drive the NEGATIVE proof. +# agent_command = [ +# "powershell", +# "-NoProfile", +# "-Command", +# "Set-Content -Path 'C:/work/openshell-mxc-demo/hello.txt' -Value 'hello from mxc'", +# ] + # Enable --debug on wxc-exec invocations. debug = false diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index d4ae601f17..51a284d416 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -71,6 +71,14 @@ pub struct MxcComputeConfig { pub agent_command: Vec, /// Working directory for the agent command inside the sandbox. pub agent_cwd: String, + /// Environment variables injected into the agent process inside the sandbox + /// (becomes MXC `process.env`). Each entry is either: + /// - `KEY=VALUE` — passed through verbatim, or + /// - `KEY` — resolved from the gateway host's environment at launch. + /// The bare-`KEY` form keeps secrets (e.g. inference API keys) out of the + /// config file: set the value on the gateway host and reference it by name. + /// Host vars that are not set are skipped with a warning. + pub agent_env: Vec, /// Host directory mapped into the sandbox as a read-write grant. /// Appears in the shared host folder for the positive-proof artifact. pub share_dir: String, @@ -96,6 +104,7 @@ impl Default for MxcComputeConfig { default_configuration_id: crate::mxc::DEFAULT_CONFIGURATION_ID.into(), agent_command: Vec::new(), agent_cwd: String::new(), + agent_env: Vec::new(), share_dir: String::new(), egress_proxy: false, egress_proxy_addr: String::new(), @@ -237,6 +246,28 @@ fn configured_egress_addr(config: &MxcComputeConfig) -> Result Vec { + let mut resolved = Vec::with_capacity(entries.len()); + for entry in entries { + if entry.contains('=') { + resolved.push(entry.clone()); + continue; + } + match std::env::var(entry) { + Ok(value) => resolved.push(format!("{entry}={value}")), + Err(_) => warn!( + var = %entry, + "agent_env passthrough variable not set in gateway host environment; skipping" + ), + } + } + resolved +} + impl MxcComputeBackend { pub fn new(config: MxcComputeConfig) -> Self { let invoker = WxcExecInvoker::new(&config.wxc_exec_path, config.debug); @@ -580,7 +611,7 @@ async fn run_lifecycle( let process = MxcProcess { command_line: command_line.clone(), cwd, - env: Vec::new(), + env: resolve_agent_env(&config.agent_env), timeout: 0, }; let network = proxy_addr.map(|addr| MxcNetwork { @@ -884,6 +915,26 @@ mod lifecycle_tests { assert!(config.egress_proxy_addr.is_empty()); } + #[test] + #[allow(unsafe_code)] // std::env::{set,remove}_var are unsafe under edition 2024 + fn resolve_agent_env_passthrough_and_host_lookup() { + // Literal KEY=VALUE passes through verbatim. + assert_eq!( + resolve_agent_env(&["FOO=bar".into()]), + vec!["FOO=bar".to_string()] + ); + + // Bare KEY for an unset host var is skipped (not emitted empty). + assert!(resolve_agent_env(&["OPENSHELL_TEST_DEFINITELY_UNSET_VAR".into()]).is_empty()); + + // Bare KEY for a set host var resolves to KEY=value from the host env. + let var = "OPENSHELL_TEST_AGENT_ENV_KEY"; + unsafe { std::env::set_var(var, "secret-123") }; + let resolved = resolve_agent_env(&[var.to_string()]); + unsafe { std::env::remove_var(var) }; + assert_eq!(resolved, vec![format!("{var}=secret-123")]); + } + #[test] fn egress_non_loopback_addr_is_rejected() { // MXC 0.6.0-alpha can only express {"proxy": {"localhost": N}}, so From dd764cf275613d66a70348181c7aa006a0521d36 Mon Sep 17 00:00:00 2001 From: Jamie King Date: Fri, 26 Jun 2026 11:03:32 -0600 Subject: [PATCH 14/19] test(driver-mxc): avoid unsafe env mutation in resolve_agent_env test Replace std::env::{set,remove}_var (unsafe + racy under parallel test execution in edition 2024) with a read-only PATH lookup. Preserves all three behaviors under test and drops the #[allow(unsafe_code)]. (cherry picked from commit ac5766eeab2db4e8cc6fcd8d8a97809edaf3df30) Signed-off-by: Jamie King --- crates/openshell-driver-mxc/src/driver.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 51a284d416..c50c708f7e 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -916,7 +916,6 @@ mod lifecycle_tests { } #[test] - #[allow(unsafe_code)] // std::env::{set,remove}_var are unsafe under edition 2024 fn resolve_agent_env_passthrough_and_host_lookup() { // Literal KEY=VALUE passes through verbatim. assert_eq!( @@ -928,11 +927,13 @@ mod lifecycle_tests { assert!(resolve_agent_env(&["OPENSHELL_TEST_DEFINITELY_UNSET_VAR".into()]).is_empty()); // Bare KEY for a set host var resolves to KEY=value from the host env. - let var = "OPENSHELL_TEST_AGENT_ENV_KEY"; - unsafe { std::env::set_var(var, "secret-123") }; + // Use PATH (guaranteed present) read-only, so the test never mutates the + // process environment (set_var/remove_var are unsafe + racy under + // parallel test execution in edition 2024). + let var = "PATH"; + let expected = std::env::var(var).expect("PATH must be set in test environment"); let resolved = resolve_agent_env(&[var.to_string()]); - unsafe { std::env::remove_var(var) }; - assert_eq!(resolved, vec![format!("{var}=secret-123")]); + assert_eq!(resolved, vec![format!("{var}={expected}")]); } #[test] From a374f5901352656173aa34e267780230fc10f2bc Mon Sep 17 00:00:00 2001 From: Jamie King Date: Tue, 28 Jul 2026 19:20:45 -0600 Subject: [PATCH 15/19] fix(mxc): adapt MXC driver to current GitHub OpenShell API The MXC driver crate was authored on GitLab against an earlier proto/core API. Adapt it to the API on GitHub main: - build_capabilities_response no longer takes supports_interactive_session - DriverSandboxSpec.gpu (bool) is now resource_requirements; detect GPU via effective_driver_gpu_count(driver_gpu_requirements(..)) - DriverSandbox gained a `workspace` field - SandboxPolicy gained `network_middlewares`: pass it through the proxy split, emit a loss item on the coarse MXC path, and account for it in the mapper drift-guard test Verified: cargo check + 75 mock-based tests pass (lib 27, examples 10, policy_mapper_matrix 38). Signed-off-by: Jamie King --- crates/openshell-driver-mxc/src/driver.rs | 11 ++++++-- .../src/policy_map/map.rs | 15 +++++++++++ .../tests/policy_mapper_matrix.rs | 25 +++++++++++++++++-- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index c50c708f7e..d4791861e3 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -7,6 +7,7 @@ use crate::mxc::{MxcFilesystem, MxcNetwork, MxcProcess, MxcProcessContainer, WxcExecInvoker}; use crate::policy::{EmbeddedPolicyMapper, MapCtx, PolicyMapper}; use futures::Stream; +use openshell_core::gpu::{driver_gpu_requirements, effective_driver_gpu_count}; use openshell_core::proto::SandboxPolicy; use openshell_core::proto::compute::v1::{ DriverCondition, DriverPlatformEvent, DriverSandbox, DriverSandboxStatus, @@ -304,13 +305,17 @@ impl MxcComputeBackend { DRIVER_NAME, DRIVER_VERSION, DEFAULT_IMAGE_SENTINEL, - false, ) } pub fn validate_sandbox_create(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { if let Some(spec) = &sandbox.spec { - if spec.gpu { + if effective_driver_gpu_count(driver_gpu_requirements( + spec.resource_requirements.as_ref(), + )) + .map_err(tonic::Status::invalid_argument)? + .is_some() + { return Err(tonic::Status::invalid_argument( "mxc driver does not support GPU sandboxes", )); @@ -819,6 +824,7 @@ fn make_sandbox_with_condition( id: base.id.clone(), name: base.name.clone(), namespace: base.namespace.clone(), + workspace: base.workspace.clone(), spec: base.spec.clone(), status: Some(DriverSandboxStatus { sandbox_name: base.name.clone(), @@ -851,6 +857,7 @@ mod lifecycle_tests { id: id.to_string(), name: id.to_string(), namespace: String::new(), + workspace: String::new(), spec: Some(DriverSandboxSpec { sandbox_token: "test-token".into(), ..Default::default() diff --git a/crates/openshell-driver-mxc/src/policy_map/map.rs b/crates/openshell-driver-mxc/src/policy_map/map.rs index 8af28c1979..33e905222a 100644 --- a/crates/openshell-driver-mxc/src/policy_map/map.rs +++ b/crates/openshell-driver-mxc/src/policy_map/map.rs @@ -114,6 +114,7 @@ pub fn split_policy(policy: &SandboxPolicy, opts: &MxcMappingOptions) -> Option< let proxy_policy = SandboxPolicy { version: policy.version, network_policies: policy.network_policies.clone(), + network_middlewares: policy.network_middlewares.clone(), ..Default::default() }; Some(SplitPolicyResult { @@ -335,6 +336,20 @@ fn map_network( opts: &MxcMappingOptions, items: &mut Vec, ) -> Vec { + if !policy.network_middlewares.is_empty() { + add_loss( + items, + "network_middlewares", + "error", + &format!( + "{} network middleware config(s) require the OpenShell host proxy and cannot be enforced by MXC directly.", + policy.network_middlewares.len() + ), + "network egress middleware", + "Middleware transformations and failure behavior would not be applied on the coarse MXC path.", + ); + } + if policy.network_policies.is_empty() { add_backend_network_loss(policy, &opts.containment, items); return Vec::new(); diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs index 597bf56a44..fc8e953fc0 100644 --- a/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs +++ b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs @@ -16,8 +16,9 @@ #![cfg(target_os = "windows")] use openshell_core::proto::{ - FilesystemPolicy, GraphqlOperation, L7Allow, L7DenyRule, L7Rule, LandlockPolicy, NetworkBinary, - NetworkEndpoint, NetworkPolicyRule, ProcessPolicy, SandboxPolicy, + FilesystemPolicy, GraphqlOperation, L7Allow, L7DenyRule, L7Rule, LandlockPolicy, + MiddlewareEndpointSelector, NetworkBinary, NetworkEndpoint, NetworkMiddlewareConfig, + NetworkPolicyRule, ProcessPolicy, SandboxPolicy, }; use openshell_driver_mxc::{ EmbeddedPolicyMapper, MapCtx, MapError, MxcMappingOptions, PolicyMapper, map_to_mxc, @@ -28,6 +29,20 @@ use serde_json::Value; // ─── helpers ──────────────────────────────────────────────────────────────── +fn middleware_config() -> NetworkMiddlewareConfig { + NetworkMiddlewareConfig { + name: "redactor".into(), + middleware: "openshell/regex".into(), + config: None, + on_error: "fail_closed".into(), + endpoints: Some(MiddlewareEndpointSelector { + include: vec!["api.example.com".into()], + exclude: Vec::new(), + }), + order: 0, + } +} + fn str_list(v: &Value) -> Vec { v.as_array() .map(|a| { @@ -1039,6 +1054,7 @@ const HANDLED_TOPLEVEL: &[&str] = &[ "landlock", "process", "network_policies", + "network_middlewares", ]; /// Per-rule keys under each network_policies entry that the mapper handles. @@ -1122,6 +1138,11 @@ fn handled_fields_inventory() { ); m }, + network_middlewares: { + let mut m = std::collections::HashMap::new(); + m.insert("redactor".to_owned(), middleware_config()); + m + }, }; // Validate so the test itself doesn't carry a bad policy. From df93c04853d884c3053ce721c57d1c4d2bd838d0 Mon Sep 17 00:00:00 2001 From: Jamie King Date: Wed, 29 Jul 2026 08:28:18 -0600 Subject: [PATCH 16/19] feat(server): wire the MXC compute driver into the gateway on Windows Register openshell-driver-mxc as the Windows-only in-process compute backend so compute_driver = "mxc" resolves to a working runtime: - ComputeRuntime::new_mxc, adapted to the current 11-arg from_driver - mxc_policy_sink A1 side channel, staged in create_sandbox before dispatch - mxc_config_from_context loader and the Mxc dispatch arm (Windows constructs; other targets return an explicit "Windows-only" error) - Windows-gated openshell-driver-mxc dependency - Mxc arms for the telemetry, config-file required-fields, and CLI reserved-builtin matches to keep them exhaustive/correct Verified with cargo check --workspace --features openshell-prover/bundled-z3 on x86_64-pc-windows-msvc, stacked on PR #2496. Signed-off-by: Jamie King --- Cargo.lock | 23 +++++++ crates/openshell-core/src/telemetry.rs | 4 ++ crates/openshell-server/Cargo.toml | 5 ++ crates/openshell-server/src/cli.rs | 1 + .../src/compute/driver_config.rs | 13 ++++ crates/openshell-server/src/compute/mod.rs | 62 +++++++++++++++++++ crates/openshell-server/src/config_file.rs | 4 +- crates/openshell-server/src/lib.rs | 29 ++++++++- 8 files changed, 138 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c0afff104b..34f73bb8da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3811,6 +3811,28 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "openshell-driver-mxc" +version = "0.0.0" +dependencies = [ + "anyhow", + "base64 0.22.1", + "clap", + "futures", + "openshell-core", + "openshell-policy", + "serde", + "serde_json", + "serde_yml", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tonic", + "tracing", + "uuid", +] + [[package]] name = "openshell-driver-podman" version = "0.0.0" @@ -4102,6 +4124,7 @@ dependencies = [ "openshell-driver-docker", "openshell-driver-kubernetes", "openshell-driver-kubernetes-secrets", + "openshell-driver-mxc", "openshell-driver-podman", "openshell-driver-vault", "openshell-gateway-interceptors", diff --git a/crates/openshell-core/src/telemetry.rs b/crates/openshell-core/src/telemetry.rs index 49ce620f4f..67afa4884f 100644 --- a/crates/openshell-core/src/telemetry.rs +++ b/crates/openshell-core/src/telemetry.rs @@ -161,6 +161,7 @@ pub enum TelemetryComputeDriver { Kubernetes, Podman, Vm, + Mxc, Unknown, } @@ -172,6 +173,7 @@ impl TelemetryComputeDriver { Self::Kubernetes => "kubernetes", Self::Podman => "podman", Self::Vm => "vm", + Self::Mxc => "mxc", Self::Unknown => "unknown", } } @@ -183,6 +185,7 @@ impl TelemetryComputeDriver { "k8s" | "kubernetes" => Self::Kubernetes, "podman" => Self::Podman, "vm" => Self::Vm, + "mxc" => Self::Mxc, _ => Self::Unknown, } } @@ -194,6 +197,7 @@ impl TelemetryComputeDriver { Some(crate::ComputeDriverKind::Kubernetes) => Self::Kubernetes, Some(crate::ComputeDriverKind::Podman) => Self::Podman, Some(crate::ComputeDriverKind::Vm) => Self::Vm, + Some(crate::ComputeDriverKind::Mxc) => Self::Mxc, None => Self::Unknown, } } diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index 6329bad823..7722d6a64a 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -118,6 +118,11 @@ openshell-driver-docker = { path = "../openshell-driver-docker" } openshell-driver-kubernetes = { path = "../openshell-driver-kubernetes" } openshell-driver-podman = { path = "../openshell-driver-podman" } +# MXC is the Windows-only in-process compute backend (openshell-driver-mxc is a +# no-op stub on other targets). It is only linked into the gateway on Windows. +[target.'cfg(target_os = "windows")'.dependencies] +openshell-driver-mxc = { path = "../openshell-driver-mxc" } + [features] default = ["telemetry"] ## Compile in anonymous telemetry emission (forwards to openshell-core/telemetry). diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 898ff4b205..f1639b822d 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -754,6 +754,7 @@ fn normalize_compute_driver_socket_args(args: &mut RunArgs, matches: &ArgMatches | ComputeDriverKind::Podman | ComputeDriverKind::Kubernetes | ComputeDriverKind::Vm + | ComputeDriverKind::Mxc ) ) { return Err(miette::miette!( diff --git a/crates/openshell-server/src/compute/driver_config.rs b/crates/openshell-server/src/compute/driver_config.rs index 9f4cac9a01..4e379d4a7b 100644 --- a/crates/openshell-server/src/compute/driver_config.rs +++ b/crates/openshell-server/src/compute/driver_config.rs @@ -13,6 +13,10 @@ pub mod builtin; use crate::config_file; use crate::defaults::LocalTlsPaths; use openshell_core::{Error, Result}; +#[cfg(target_os = "windows")] +use openshell_core::ComputeDriverKind; +#[cfg(target_os = "windows")] +use openshell_driver_mxc::MxcComputeConfig; use serde::Deserialize; use std::collections::BTreeMap; use std::path::PathBuf; @@ -43,6 +47,15 @@ pub struct DriverStartupContext<'a> { pub endpoint_overrides: &'a BTreeMap, } +/// Build the selected MXC config from TOML. MXC is Windows-only and has no +/// runtime-default overlay; the driver reads its own settings from the config. +/// The Linux built-in driver configs now live in the `builtin` submodule +/// (compiled only off Windows). +#[cfg(target_os = "windows")] +pub fn mxc_config_from_context(context: DriverStartupContext<'_>) -> Result { + driver_config_from_context(context, ComputeDriverKind::Mxc.as_str()) +} + pub fn remote_driver_config_from_context( context: DriverStartupContext<'_>, name: &str, diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 3aaa0ddf8c..037c17ab10 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -56,6 +56,10 @@ use openshell_driver_kubernetes::{ }; #[cfg(not(target_os = "windows"))] use openshell_driver_podman::{ComputeDriverService as PodmanDriverService, PodmanComputeDriver}; +#[cfg(target_os = "windows")] +use openshell_driver_mxc::{ComputeDriverService as MxcDriverService, MxcComputeConfig}; +#[cfg(target_os = "windows")] +use openshell_core::proto::SandboxPolicy; use prost::Message; use std::collections::HashMap; use std::fmt; @@ -576,6 +580,14 @@ pub struct ComputeRuntime { delete_gates: Arc, gateway_listener_requirements: Vec, replica_id: String, + /// A1 policy side channel for the in-process MXC driver. `create_sandbox` + /// stages the typed `SandboxPolicy` here by sandbox id immediately before + /// dispatching to the driver, which consumes it. `None` for all other + /// drivers. The proto driver contract has no `policy` field and there is no + /// driver-side `GetSandboxConfig`, so this in-process map is how the policy + /// reaches the MXC backend without changing the cross-process contract. + #[cfg(target_os = "windows")] + mxc_policy_sink: Option>>>, } impl fmt::Debug for ComputeRuntime { @@ -697,6 +709,8 @@ impl ComputeRuntime { delete_gates: Arc::new(DeleteGateRegistry::default()), gateway_listener_requirements, replica_id: lease::replica_id(), + #[cfg(target_os = "windows")] + mxc_policy_sink: None, }) } @@ -838,6 +852,40 @@ impl ComputeRuntime { .await } + /// Construct a `ComputeRuntime` backed by the MXC compute driver. + /// + /// MXC is Windows-only, in-process, and self-reports `Ready` — there is + /// no supervisor session argument because no surrogate or relay is used. + #[cfg(target_os = "windows")] + pub async fn new_mxc( + mxc_config: MxcComputeConfig, + store: Arc, + sandbox_index: SandboxIndex, + sandbox_watch_bus: SandboxWatchBus, + tracing_log_bus: TracingLogBus, + supervisor_sessions: Arc, + ) -> Result { + let backend = openshell_driver_mxc::MxcComputeBackend::new(mxc_config); + // Grab the A1 policy side channel before moving `backend` into the service. + let sink = backend.policy_sink(); + let service: SharedComputeDriver = Arc::new(MxcDriverService::new(backend)); + let mut runtime = Self::from_driver( + ComputeDriverKind::Mxc.as_str().to_string(), + service, + None, + None, + None, + store, + sandbox_index, + sandbox_watch_bus, + tracing_log_bus, + supervisor_sessions, + ) + .await?; + runtime.mxc_policy_sink = Some(sink); + Ok(runtime) + } + #[must_use] pub fn default_image(&self) -> &str { &self.default_image @@ -929,6 +977,16 @@ impl ComputeRuntime { { spec.sandbox_token = token; } + // A1: stage the typed SandboxPolicy out-of-band into the MXC backend's + // side channel, keyed by sandbox id (== DriverSandbox.id), immediately + // before dispatch. The driver removes/consumes it in create_sandbox. The + // proto driver contract has no policy field, so this is the only path. + #[cfg(target_os = "windows")] + if let Some(sink) = &self.mxc_policy_sink + && let Some(p) = sandbox.spec.as_ref().and_then(|s| s.policy.clone()) + { + sink.lock().await.insert(sandbox_id.clone(), p); + } match self .driver .call( @@ -3238,6 +3296,8 @@ pub async fn new_test_runtime_for_driver(store: Arc, driver_name: &str) - delete_gates: Arc::new(DeleteGateRegistry::default()), gateway_listener_requirements: Vec::new(), replica_id: "test-replica".to_string(), + #[cfg(target_os = "windows")] + mxc_policy_sink: None, } } @@ -3725,6 +3785,8 @@ mod tests { delete_gates: Arc::new(DeleteGateRegistry::default()), gateway_listener_requirements: Vec::new(), replica_id: "test-replica".to_string(), + #[cfg(target_os = "windows")] + mxc_policy_sink: None, } } diff --git a/crates/openshell-server/src/config_file.rs b/crates/openshell-server/src/config_file.rs index 39166333be..9cfafd0cba 100644 --- a/crates/openshell-server/src/config_file.rs +++ b/crates/openshell-server/src/config_file.rs @@ -388,7 +388,9 @@ fn inheritable_keys(driver_name: &str) -> &'static [&'static str] { "guest_tls_cert", "guest_tls_key", ], - None => &[], + // MXC reads its own settings from the driver config table and has no + // gateway-inherited required fields. + Some(ComputeDriverKind::Mxc) | None => &[], } } diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index a7bf847d9a..dbeaa33ffe 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -864,8 +864,6 @@ async fn build_compute_runtime( info!(driver = %driver.name(), "Using compute driver"); let runtime = match driver { - #[cfg(target_os = "windows")] - ConfiguredComputeDriver::Builtin(driver) => Err(unsupported_builtin_compute_driver(driver)), #[cfg(not(target_os = "windows"))] ConfiguredComputeDriver::Builtin(ComputeDriverKind::Kubernetes) => { warn_if_kubernetes_sandbox_jwt_expiry_disabled(config); @@ -928,6 +926,33 @@ async fn build_compute_runtime( ) .await } + ConfiguredComputeDriver::Builtin(ComputeDriverKind::Mxc) => { + #[cfg(target_os = "windows")] + { + let mxc_config = compute::driver_config::mxc_config_from_context(driver_startup)?; + ComputeRuntime::new_mxc( + mxc_config, + store, + sandbox_index, + sandbox_watch_bus, + tracing_log_bus, + supervisor_sessions, + ) + .await + } + #[cfg(not(target_os = "windows"))] + { + Err(compute::ComputeError::Message( + "the mxc compute driver is only supported on Windows".to_string(), + )) + } + } + // Any remaining built-in driver is a Linux-only container backend whose + // crate is not compiled on Windows; report it as unsupported. Must sit + // after the MXC arm so MXC (the one Windows-supported built-in) matches + // first instead of being swallowed by this catch-all. + #[cfg(target_os = "windows")] + ConfiguredComputeDriver::Builtin(driver) => Err(unsupported_builtin_compute_driver(driver)), ConfiguredComputeDriver::Remote { name } => { let remote_config = compute::driver_config::remote_driver_config_from_context(driver_startup, &name)?; From 9ca59afd0651e3e29d3410896edee1a0f37ad203 Mon Sep 17 00:00:00 2001 From: Jamie King Date: Tue, 11 Aug 2026 12:38:43 -0600 Subject: [PATCH 17/19] fix(driver-mxc): implement GetGatewayListenerRequirements for #2496 base Signed-off-by: Jamie King --- crates/openshell-driver-mxc/src/grpc.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/openshell-driver-mxc/src/grpc.rs b/crates/openshell-driver-mxc/src/grpc.rs index 930a3112c1..71b5d3f154 100644 --- a/crates/openshell-driver-mxc/src/grpc.rs +++ b/crates/openshell-driver-mxc/src/grpc.rs @@ -10,7 +10,8 @@ use crate::driver::MxcComputeBackend; use futures::{Stream, StreamExt}; use openshell_core::proto::compute::v1::{ CreateSandboxRequest, CreateSandboxResponse, DeleteSandboxRequest, DeleteSandboxResponse, - GetCapabilitiesRequest, GetCapabilitiesResponse, GetSandboxRequest, GetSandboxResponse, + GetCapabilitiesRequest, GetCapabilitiesResponse, GetGatewayListenerRequirementsRequest, + GetGatewayListenerRequirementsResponse, GetSandboxRequest, GetSandboxResponse, ListSandboxesRequest, ListSandboxesResponse, StopSandboxRequest, StopSandboxResponse, ValidateSandboxCreateRequest, ValidateSandboxCreateResponse, WatchSandboxesEvent, WatchSandboxesRequest, compute_driver_server::ComputeDriver, @@ -38,6 +39,17 @@ impl ComputeDriver for ComputeDriverService { Ok(Response::new(self.backend.capabilities())) } + async fn get_gateway_listener_requirements( + &self, + _request: Request, + ) -> Result, Status> { + // MXC is an in-process, single-host driver: it needs no extra gateway + // listeners (no relay/surrogate/remote endpoint), so it reports none. + Ok(Response::new(GetGatewayListenerRequirementsResponse { + requirements: Vec::new(), + })) + } + async fn validate_sandbox_create( &self, request: Request, From 7beadcbaa26e4f08b854b58f2bfef526a02e8609 Mon Sep 17 00:00:00 2001 From: Giedrius Burachas Date: Thu, 11 Jun 2026 18:04:10 -0700 Subject: [PATCH 18/19] test(driver-mxc): add probe-gated real wxc-exec test lane (no mocks) - tests/wxc_exec_real.rs: ignored-by-default integration tests against a real wxc-exec. Six --dry-run contract tests run wherever the binary exists (they caught the network.proxy shape mismatch); enforcement tests (processcontainer default-deny positive/negative, isolation session lifecycle round trip with a deprovision drop-guard) probe the backend and SKIP with a recorded reason where it is not live. - examples/probe-mxc-host.ps1: classifies a host (OS build, --probe, per-backend trial) and emits a JSON capability verdict. - examples/run-mxc-e2e.ps1 + e2e-policies/: scenario runner generalizing run-demo.ps1 (fs-rw, fs-readonly, fs-default-deny-empty, network-policy-rejected) with PASS/FAIL/SKIP gating and a stale OPENSHELL_MXC_MOCK_WXC guard in real mode. - tasks/windows.toml: windows:test:mxc-real:x64, windows:e2e:mxc, windows:e2e:mxc:mock. Signed-off-by: Giedrius Burachas (cherry picked from commit 49afafe892caded59c4df50a9b652011ad97f41c) Signed-off-by: Jamie King --- crates/openshell-driver-mxc/README.md | 29 ++++++++++++++++++ .../examples/e2e-policies/fs-empty.yaml | 15 ++++++++++ .../examples/e2e-policies/fs-readonly.yaml | 16 ++++++++++ .../examples/e2e-policies/fs-rw.yaml | 14 +++++++++ .../examples/e2e-policies/network-reject.yaml | 30 +++++++++++++++++++ tasks/windows.toml | 15 ++++++++++ 6 files changed, 119 insertions(+) create mode 100644 crates/openshell-driver-mxc/examples/e2e-policies/fs-empty.yaml create mode 100644 crates/openshell-driver-mxc/examples/e2e-policies/fs-readonly.yaml create mode 100644 crates/openshell-driver-mxc/examples/e2e-policies/fs-rw.yaml create mode 100644 crates/openshell-driver-mxc/examples/e2e-policies/network-reject.yaml diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 70027f6bc2..fa50a8fcd8 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -108,6 +108,35 @@ the demo Windows host and follow `mxc-demo-runbook.md` inside it. The script prints a SHA256 manifest so the operator can sanity-check what landed before moving it. +## Real-MXC test lane + +Three tasks drive real `wxc-exec.exe` hardware; all are **skip-safe** — any test +or scenario that requires an absent binary or backend prints a SKIP reason and +exits 0 rather than failing. + +| Task | What it runs | When to use | +|---|---|---| +| `windows:test:mxc-real:x64` | `tests/wxc_exec_real.rs` — Tier-2 invoker tests with `--ignored --test-threads=1` | Pre-merge on any Windows host that has `wxc-exec`; dry-run tests always pass; enforcement tests probe-gate themselves | +| `windows:e2e:mxc` | `examples/run-mxc-e2e.ps1` — Tier-3 scenario runner, real binary, probe-gated | Demo box / nightly; needs the gateway + CLI binaries in the script directory | +| `windows:e2e:mxc:mock` | Same runner with `-Mock` — wiring-only, no real `wxc-exec` needed | Any Windows host (CI, dev machine); validates wiring and the network-reject scenario | + +**Probe script:** `examples/probe-mxc-host.ps1` emits a JSON capability report +(OS build, wxc-exec path/version, dry-run exit code, per-backend trial result, +and a `verdicts` object). Run it before the real-MXC lane to understand what +will PASS vs SKIP on a given host: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass ` + -File crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 +``` + +**Skip semantics:** tests in `wxc_exec_real.rs` are marked +`#[ignore = "requires real wxc-exec"]` — the standard `windows:test:x64` suite +never runs them. `OPENSHELL_WXC_EXEC_PATH` overrides the default +`C:\mxc\wxc-exec.exe` lookup. See `docs4gtb/mxc-box-capabilities.md` for the +empirical capability snapshot of the development box (build 26200, processcontainer +velocity keys not enabled, isolation_session absent). + ## Deferred work - **Interactive exec/connect/forward** → `adapt-openshell-gateway-windows` diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/fs-empty.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/fs-empty.yaml new file mode 100644 index 0000000000..8be1dec9cf --- /dev/null +++ b/crates/openshell-driver-mxc/examples/e2e-policies/fs-empty.yaml @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# fs-empty.yaml — Empty filesystem policy (default-deny) scenario. +# +# No paths are granted. With processcontainer (AppContainer), every write is +# denied by the OS without any host ACL configuration — genuine default-deny. +# This scenario is processcontainer-only (isolation_session has no deny primitive). +# Used by the fs-default-deny-empty scenario in run-mxc-e2e.ps1. +version: 1 + +filesystem_policy: + include_workdir: false + read_only: [] + read_write: [] diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/fs-readonly.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/fs-readonly.yaml new file mode 100644 index 0000000000..7fd2d0864b --- /dev/null +++ b/crates/openshell-driver-mxc/examples/e2e-policies/fs-readonly.yaml @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# fs-readonly.yaml — Read-only grant + read-write share scenario. +# +# The read_only path is a prepared directory whose content the agent can read +# but not write; the read_write path is DemoDir (host-visible share). +# Used by the fs-readonly scenario in run-mxc-e2e.ps1. +version: 1 + +filesystem_policy: + include_workdir: false + read_only: + - "C:/work/openshell-mxc-e2e-ro-src" + read_write: + - "C:/work/openshell-mxc-e2e" diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/fs-rw.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/fs-rw.yaml new file mode 100644 index 0000000000..38dafa5ed5 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/e2e-policies/fs-rw.yaml @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# fs-rw.yaml — Filesystem read-write grant scenario. +# +# Grants read-write access to the DemoDir (substituted at runtime). +# Used by the fs-rw-positive-negative scenario in run-mxc-e2e.ps1. +version: 1 + +filesystem_policy: + include_workdir: false + read_only: [] + read_write: + - "C:/work/openshell-mxc-e2e" diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/network-reject.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/network-reject.yaml new file mode 100644 index 0000000000..47844069b9 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/e2e-policies/network-reject.yaml @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# network-reject.yaml — Network policy rejection scenario. +# +# A filesystem grant plus a network_policies rule. On isolation_session the +# driver rejects sandbox create at map time with invalid_argument naming the +# network rule (M1 not yet landed). On processcontainer with egress_proxy +# disabled, network policy is also rejected. +# +# This scenario requires NO live backend — it passes even on this box and in +# mock mode because the rejection happens in the policy mapper before wxc-exec +# is invoked. It is the only scenario that never SKIPs. +version: 1 + +filesystem_policy: + include_workdir: false + read_only: [] + read_write: + - "C:/work/openshell-mxc-e2e" + +network_policies: + test_rule: + name: test-network-reject + endpoints: + - host: api.example.com + port: 443 + protocol: rest + enforcement: enforce + access: read-only diff --git a/tasks/windows.toml b/tasks/windows.toml index c7a2e51f99..59c2a8f2c2 100644 --- a/tasks/windows.toml +++ b/tasks/windows.toml @@ -63,3 +63,18 @@ run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts description = "Run Windows MSVC checks, release builds, x64 tests, and unsupported-driver contract tests" run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 ci all" + +["windows:test:mxc-real:x64"] +description = "Run real-wxc-exec Tier-2 integration tests (skip-safe: tests print SKIP when binary/backend absent)" +run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" +run_windows = "cargo test -p openshell-driver-mxc --test wxc_exec_real --target x86_64-pc-windows-msvc -- --ignored --test-threads=1" + +["windows:e2e:mxc"] +description = "Run MXC Tier-3 e2e scenario runner against real wxc-exec (probe-gated; skip-safe on hosts without the binary)" +run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1" + +["windows:e2e:mxc:mock"] +description = "Run MXC Tier-3 e2e scenario runner in mock/wiring-only mode (no real wxc-exec required)" +run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 -Mock" From 70df438d99ddf3e2dafe89fcfef631164bcbbbe3 Mon Sep 17 00:00:00 2001 From: Shailendra Singh Date: Thu, 20 Aug 2026 11:22:13 -0700 Subject: [PATCH 19/19] fix(driver-mxc): address PR review feedback Signed-off-by: Shailendra Singh --- .agents/skills/openshell-cli/SKILL.md | 4 +- .agents/skills/openshell-cli/cli-reference.md | 4 +- Cargo.lock | 2 - architecture/README.md | 4 + architecture/windows-msvc-build.md | 12 +- crates/openshell-driver-mxc/Cargo.toml | 6 +- crates/openshell-driver-mxc/README.md | 117 +-- .../openshell-driver-mxc/examples/demo.yaml | 13 +- .../examples/e2e-policies/network-reject.yaml | 7 +- .../examples/mxc-gateway.toml | 74 +- .../examples/policy-to-mxc.rs | 454 --------- .../examples/probe-mxc-host.ps1 | 5 +- .../examples/run-mxc-e2e.ps1 | 54 +- crates/openshell-driver-mxc/src/driver.rs | 907 +++++++++--------- crates/openshell-driver-mxc/src/lib.rs | 2 +- crates/openshell-driver-mxc/src/mxc.rs | 73 +- crates/openshell-driver-mxc/src/policy.rs | 74 +- .../src/policy_map/map.rs | 6 +- .../tests/policy_mapper_matrix.rs | 7 +- .../tests/wxc_exec_real.rs | 89 +- crates/openshell-server/src/cli.rs | 9 +- .../src/compute/driver_config.rs | 2 +- crates/openshell-server/src/compute/mod.rs | 8 +- crates/openshell-server/src/grpc/policy.rs | 36 + docs/about/how-it-works.mdx | 6 +- docs/reference/gateway-config.mdx | 26 + docs/reference/sandbox-compute-drivers.mdx | 39 +- docs/sandboxes/manage-sandboxes.mdx | 4 +- 28 files changed, 771 insertions(+), 1273 deletions(-) delete mode 100644 crates/openshell-driver-mxc/examples/policy-to-mxc.rs diff --git a/.agents/skills/openshell-cli/SKILL.md b/.agents/skills/openshell-cli/SKILL.md index 462e27f3f2..484ad1509f 100644 --- a/.agents/skills/openshell-cli/SKILL.md +++ b/.agents/skills/openshell-cli/SKILL.md @@ -304,7 +304,7 @@ openshell sandbox delete --all This is the most important multi-step workflow. It enables a tight feedback cycle where sandbox policy is refined based on observed activity. -**Key concept**: Policies have static fields (immutable after creation: `filesystem_policy`, `landlock`, `process`) and two dynamic fields: `network_policies` and `network_middlewares`. Both dynamic fields can be updated without recreating the sandbox. +**Key concept**: Policies have static fields (immutable after creation: `filesystem_policy`, `landlock`, `process`) and two dynamic fields: `network_policies` and `network_middlewares`. Both dynamic fields can be updated without recreating the sandbox when the selected compute driver supports live policy updates. MXC rejects live policy replacement and merge updates; delete and recreate an MXC sandbox instead. ``` Create sandbox with initial policy @@ -369,7 +369,7 @@ Edit `current-policy.yaml` to allow the blocked actions. **For policy content au - Binary matching patterns - Ordered `network_middlewares`, host selection, and `fail_open` or `fail_closed` behavior -`network_policies` and `network_middlewares` can be modified at runtime. If `filesystem_policy`, `landlock`, or `process` need changes, the sandbox must be recreated. Built-in middleware such as `openshell/regex` needs no gateway registration. An operator-run middleware must already be registered under `[[openshell.supervisor.middleware]]`; changing that static registration requires a gateway restart. +`network_policies` and `network_middlewares` can be modified at runtime when the selected compute driver supports live policy updates. MXC rejects live policy replacement and merge updates; delete and recreate an MXC sandbox instead. If `filesystem_policy`, `landlock`, or `process` need changes, the sandbox must be recreated. Built-in middleware such as `openshell/regex` needs no gateway registration. An operator-run middleware must already be registered under `[[openshell.supervisor.middleware]]`; changing that static registration requires a gateway restart. ### Step 5: Push the updated policy diff --git a/.agents/skills/openshell-cli/cli-reference.md b/.agents/skills/openshell-cli/cli-reference.md index 30d6fb7ed3..536bab2729 100644 --- a/.agents/skills/openshell-cli/cli-reference.md +++ b/.agents/skills/openshell-cli/cli-reference.md @@ -353,7 +353,7 @@ The sandbox name defaults to the last-used sandbox. ### `openshell policy update [name]` -Incrementally merge live network policy changes into the current sandbox policy. Multiple flags in one invocation are applied as one atomic batch and create at most one new revision. +Incrementally merge live network policy changes into the current sandbox policy when the selected compute driver supports live updates. Multiple flags in one invocation are applied as one atomic batch and create at most one new revision. MXC rejects live policy merges; delete and recreate an MXC sandbox instead. | Flag | Default | Description | |------|---------|-------------| @@ -377,7 +377,7 @@ Notes: ### `openshell policy set [name] --policy ` -Replace the full policy on a live sandbox. Only the dynamic `network_policies` field can be changed at runtime. +Replace the full policy on a live sandbox when the selected compute driver supports live updates. Only the dynamic `network_policies` field can be changed at runtime. MXC rejects live policy replacement; delete and recreate an MXC sandbox instead. | Flag | Default | Description | |------|---------|-------------| diff --git a/Cargo.lock b/Cargo.lock index 34f73bb8da..b945ad0eef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3815,9 +3815,7 @@ dependencies = [ name = "openshell-driver-mxc" version = "0.0.0" dependencies = [ - "anyhow", "base64 0.22.1", - "clap", "futures", "openshell-core", "openshell-policy", diff --git a/architecture/README.md b/architecture/README.md index 9453d59826..648023cd03 100644 --- a/architecture/README.md +++ b/architecture/README.md @@ -141,6 +141,10 @@ bridge networks, port mappings, NAT traversal, or bespoke tunnels. The common runtime requirement is narrower: the supervisor must be able to reach the gateway. +The Windows MXC driver is an explicit exception. It launches and monitors a +one-shot workload in the driver, self-reports readiness, and does not expose a +supervisor session, interactive connect, live policy delivery, or governed egress. + The gateway delivers desired state; the sandbox applies it locally. Policy, settings, credentials, and inference routes flow from the gateway to the supervisor. The supervisor validates and applies what can change at runtime, diff --git a/architecture/windows-msvc-build.md b/architecture/windows-msvc-build.md index df875bc8ef..e940b83273 100644 --- a/architecture/windows-msvc-build.md +++ b/architecture/windows-msvc-build.md @@ -1,14 +1,15 @@ # Windows MSVC Build Design This page records the design decisions for the native Windows MSVC build lane. -It is intentionally build-only. It does not make Windows a Docker, Kubernetes, -Podman, or VM runtime host. +It provides the native build lane and validates the in-process MXC compute +driver. It does not make Windows a Docker, Kubernetes, Podman, or VM runtime host. ## Goals - Compile the OpenShell gateway and CLI for `x86_64-pc-windows-msvc` and `aarch64-pc-windows-msvc`. - Keep the Linux and macOS build paths unchanged. - Preserve gateway configuration parsing for all existing compute driver names. +- Build and test the in-process MXC driver on supported Windows hosts. - Return clear unsupported errors when a Windows gateway is configured to use Docker, Kubernetes, Podman, or VM. - Keep dedicated `windows:*` validation tasks while allowing the repository-wide `pre-commit` task to delegate compiler-bearing Rust checks to the native @@ -18,7 +19,7 @@ Podman, or VM runtime host. - Do not support Docker Desktop, WSL, Hyper-V, Podman machine, Podman Desktop, Kubernetes, or VM-backed sandbox execution on Windows. - Do not ship Windows standalone binaries for Docker, Kubernetes, Podman, or VM drivers. -- Do not implement named-pipe driver IPC, Windows services, MSI packaging, Credential Manager integration, DPAPI integration, or MXC policy translation in this build lane. +- Do not implement named-pipe driver IPC, Windows services, MSI packaging, Credential Manager integration, or DPAPI integration in this lane. ## Unsupported Driver Strategy @@ -42,6 +43,7 @@ on Windows. | Kubernetes | Driver crate excluded; server config contract retained. | Gateway construction returns unsupported. | | Podman | Driver crate excluded; server config contract retained. | Gateway construction returns unsupported. | | VM | Driver crate excluded from workspace validation. | Gateway construction returns unsupported. | +| MXC | Driver links into the native gateway and runs in Windows validation. | `process_container` is default-deny; grant-only `isolation_session` requires explicit configuration. | This keeps Windows behavior explicit without carrying runtime dependencies or creating misleading Windows driver artifacts. @@ -62,7 +64,7 @@ Windows validation is exposed through `tasks/windows.toml`: | `windows:check:arm64` | Check the ARM64 MSVC gateway/CLI build graph. | | `windows:build:x64` | Build release x64 `openshell-gateway.exe` and `openshell.exe`. | | `windows:build:arm64` | Build release ARM64 `openshell-gateway.exe` and `openshell.exe`. | -| `windows:test:x64` | Run native x64 workspace tests, excluding unsupported Windows packages as top-level test targets. | +| `windows:test:x64` | Run native x64 workspace tests, including MXC mapper and lifecycle tests, while excluding unsupported Windows packages as top-level test targets. | | `windows:test:arm64` | Run native ARM64 workspace tests with the same package exclusions. | | `windows:test:unsupported:x64` | Run focused server/runtime tests for unsupported driver contracts. | | `windows:test:unsupported:arm64` | Run the same focused contracts natively on ARM64. | @@ -159,5 +161,5 @@ A successful Windows build report should include: - Focused unsupported-driver contract test status. - Artifact size and SHA256 for each Windows binary. -Warnings from Linux-only dead code are acceptable in this build-only phase when +Warnings from Linux-only dead code are acceptable in the native Windows lane when they come from code paths intentionally disabled on Windows. diff --git a/crates/openshell-driver-mxc/Cargo.toml b/crates/openshell-driver-mxc/Cargo.toml index 3402af7a3a..2f82e50e3b 100644 --- a/crates/openshell-driver-mxc/Cargo.toml +++ b/crates/openshell-driver-mxc/Cargo.toml @@ -30,11 +30,9 @@ uuid = { workspace = true } tokio = { workspace = true } # tempfile is not a workspace dependency; 3.27 is already resolved in Cargo.lock. tempfile = "3" -# Used only by the Windows-only example + integration test (parse policy YAML -# into the typed proto, and drive the CLI). Inert on non-Windows. +# Used by Windows-only integration tests to parse policy YAML into the typed +# proto. Inert on non-Windows. openshell-policy = { path = "../openshell-policy" } -clap = { workspace = true } -anyhow = { workspace = true } # Needed by the real-wxc integration test (wxc_exec_real.rs) which builds # --config-base64 payloads without going through the async WxcExecInvoker. # base64 and serde_json are already [dependencies] but dev-dependency resolution diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index fa50a8fcd8..6496dfe286 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -4,66 +4,55 @@ OpenShell compute driver backed by **Microsoft MXC** (`wxc-exec`) on Windows. ## Design -This driver implements the gateway's `ComputeDriver` gRPC contract as an -**in-process library** linked into `openshell-gateway`. It drives MXC through -the state-aware lifecycle (`provision` → `start` → `exec` → `stop` → -`deprovision`), runs the agent **inside the driver** (exec-in-driver), and -**self-reports readiness** — there is no in-sandbox supervisor, no host-side -surrogate, and no `ConnectSupervisor` relay. See -`docs/reference/mxc-compute-driver-design.mdx` for Shailendra's full -architectural rationale (decisions D1–D4). - -## Capability Matrix (June 15 demo slice) - -| Capability | MXC driver | Closing it requires | -|---|---|---| -| Filesystem policy (read-write / read-only grants) | ✅ provision-time AppContainer shares | — | -| Governed egress (CONNECT proxy + OPA + L7) | Available behind `egress_proxy` on `process_container`; host proxy integration is the next consumer | `implement-openshell-mxc-egress-proxy` | -| Network policy | Split into MXC `network.proxy` + trimmed OpenShell policy on `process_container`; `isolation_session` still rejects network config | MXC feedback item M1 for persistent sessions | -| Process policy (seccomp, uid/gid) | ❌ host-side governance design; OS isolation only | not pursued | -| Interactive exec/connect/forward | ❌ exec runs in-driver, no client attach | `adapt-openshell-gateway-windows` | -| Bundled agent image | ❌ no OCI image; relies on Windows host install | — | -| Restart durability | ❌ in-memory registry; restart orphans live sessions | follow-on | -| Concurrent sandboxes | ⚠️ isolation_session v1 is single-session | MXC backend feature | - -The June 15 demo proof point is **filesystem policy enforcement**: -- **Positive**: write to the in-policy `share_dir` succeeds; `hello.txt` appears on the host. -- **Negative**: write outside the policy fails with Windows access-denied; driver emits a - `DriverPlatformEvent` denial and the exec exits non-zero. +This driver implements the gateway's `ComputeDriver` contract as an in-process +library linked into `openshell-gateway`. `process_container` launches a one-shot +AppContainer and is the default. The opt-in `isolation_session` backend uses the +state-aware `provision` → `start` → `exec` → `stop` → `deprovision` lifecycle. +The driver launches and monitors the configured workload itself and self-reports +readiness; there is no in-sandbox supervisor or `ConnectSupervisor` relay. + +## Capability Matrix + +| Capability | MXC driver | +|---|---| +| Filesystem policy | Read-only/read-write grants come only from `SandboxPolicy`. `process_container` enforces default-deny; `isolation_session` is an explicit grant-only compatibility mode. | +| Network policy | Rejected synchronously during sandbox creation until an enforcing egress path is bound. | +| Process policy | Unsupported; MXC supplies OS isolation only. | +| Interactive exec/connect/forward | Unsupported; the configured workload runs in-driver. | +| Restart durability | Unsupported; the in-memory registry cannot recover live sessions. | + +The filesystem enforcement proof has two paths: + +- A write to a path granted by the sandbox policy succeeds. +- A `process_container` write outside the sandbox policy fails with Windows access denied, and the driver reports the failed workload. ## Configuration (`[openshell.drivers.mxc]`) +Gateway configuration contains only host runtime settings: + ```toml [openshell.drivers.mxc] -# Path to wxc-exec.exe (required for live runs) wxc_exec_path = "C:\\path\\to\\wxc-exec.exe" -# MXC backend: "isolation_session" (default) or "process_container" +# Default: process_container. isolation_session is grant-only and opt-in. backend = "process_container" -# MXC configurationId — never use "small" (known OS bug) default_configuration_id = "composable" -# process_container-only options pc_least_privilege = false pc_capabilities = [] -# Agent command executed inside the sandbox -agent_command = ["cmd", "/c", "echo hello > C:\\work\\demo\\hello.txt"] -# Working directory for the agent (defaults to share_dir) -agent_cwd = "C:\\work\\demo" -# Host directory mapped read-write into the sandbox -share_dir = "C:\\work\\demo" -# Pattern C governed egress. Requires backend = "process_container" until -# MXC M1 adds network.proxy support for isolation_session. -egress_proxy = false -egress_proxy_addr = "" -# Enable --debug on wxc-exec invocations debug = false ``` -Or via environment / CLI: -``` -OPENSHELL_DRIVERS=mxc openshell-gateway ... -openshell-gateway --drivers mxc ... +Supply workload settings for each sandbox. The public config is keyed by driver name; the gateway forwards only the inner `mxc` object to the driver: + +```powershell +$config = '{"mxc":{"command":["cmd","/c","echo hello > C:\\\\work\\\\demo\\\\hello.txt"],"cwd":"C:\\\\work\\\\demo"}}' +openshell sandbox create --name mxc-demo --policy demo.yaml ` + --driver-config-json $config --env MODE=demo --no-tty ``` +The `command` array is required and preserves Windows argument boundaries. `cwd` is optional. Environment variables come from the standard sandbox and template environment maps; the driver never copies values from the gateway host environment. + +Network policy and live policy replacement or merge updates are rejected while the gateway uses MXC. Delete and recreate the sandbox to apply a different filesystem policy. + ## Prerequisites (live runs) - Windows 11 Insider build ≥ 26300.8553 @@ -73,31 +62,15 @@ openshell-gateway --drivers mxc ... For off-box smoke tests against the in-process mock shim (no `wxc-exec`, no isolation session needed), set `OPENSHELL_MXC_MOCK_WXC=1`. -## PolicyMapper seam - -Policy translation (`SandboxPolicy` → MXC `ContainerConfig`) is delegated to -a `policy::PolicyMapper` trait. The primary implementation, -`EmbeddedPolicyMapper`, calls the embedded [`policy_map`](src/policy_map/) -module's `map_to_mxc` directly on the typed proto (no YAML bridge), then -normalizes the resulting filesystem paths to Windows form. `policy_map/` is -the **source of truth** for the OpenShell→MXC mapping — it was the standalone -`openshell-policy-mapper` crate, now embedded as a module here. The original -`StubPolicyMapper` is retained as a documented, compile-only fallback that only -maps `share_dir`. - -When `egress_proxy` is enabled, `EmbeddedPolicyMapper` uses `split_policy` -instead: MXC receives filesystem grants plus a loopback `network.proxy` -redirect, and the driver stores the trimmed network-only `SandboxPolicy` for -the host CONNECT proxy. The development export surface remains the -[`policy-to-mxc`](examples/policy-to-mxc.rs) example; there is no production -`openshell policy export-mxc` subcommand yet. - -Everything in this crate — including the mapper, the -[`policy-to-mxc`](examples/policy-to-mxc.rs) example, and the parity tests in -[`tests/policy_mapper_examples.rs`](tests/policy_mapper_examples.rs) — is -Windows-only (`#[cfg(target_os = "windows")]`); the crate is an empty stub on -other platforms. The mapper's parity tests therefore run on the Windows MSVC -test lane (`mise run windows:test:x64`), not the Linux lane. +## Policy mapping + +The production driver maps the typed `SandboxPolicy` to MXC configuration before it inserts a registry entry or invokes `wxc-exec`. Mapping failure therefore returns from `CreateSandbox` without leaving a partial sandbox. + +`EmbeddedPolicyMapper` calls the embedded [`policy_map`](src/policy_map/) module directly and normalizes filesystem paths to Windows form. It does not add gateway-configured host paths. The policy supplied for the sandbox is the only source of filesystem grants. + +The mapper retains an internal policy-splitting seam for future development, but the runtime exposes no governed-egress switch. Any network rule fails closed until an enforcing proxy is implemented and bound to the sandbox lifecycle. + +Parity and matrix tests under [`tests/`](tests/) cover the mapper on the Windows MSVC lane. The driver performs this mapping automatically; there is no separate policy-export command or example. ## Packaging the demo for the demo box @@ -120,7 +93,7 @@ exits 0 rather than failing. | `windows:e2e:mxc` | `examples/run-mxc-e2e.ps1` — Tier-3 scenario runner, real binary, probe-gated | Demo box / nightly; needs the gateway + CLI binaries in the script directory | | `windows:e2e:mxc:mock` | Same runner with `-Mock` — wiring-only, no real `wxc-exec` needed | Any Windows host (CI, dev machine); validates wiring and the network-reject scenario | -**Probe script:** `examples/probe-mxc-host.ps1` emits a JSON capability report +**Probe script:** `examples/probe-mxc-host.ps1` is an operator/CI preflight that emits a JSON capability report (OS build, wxc-exec path/version, dry-run exit code, per-backend trial result, and a `verdicts` object). Run it before the real-MXC lane to understand what will PASS vs SKIP on a given host: @@ -140,6 +113,6 @@ velocity keys not enabled, isolation_session absent). ## Deferred work - **Interactive exec/connect/forward** → `adapt-openshell-gateway-windows` -- **Governed egress proxy implementation** → `implement-openshell-mxc-egress-proxy` +- **Governed egress** remains fail-closed until an enforcing proxy is implemented and bound to sandbox lifecycle. - **Restart durability** (deprovision orphaned sessions on startup) → follow-on - **GPU passthrough** → not pursued in host-side-governance design diff --git a/crates/openshell-driver-mxc/examples/demo.yaml b/crates/openshell-driver-mxc/examples/demo.yaml index 0aedd0da68..1dcc23144f 100644 --- a/crates/openshell-driver-mxc/examples/demo.yaml +++ b/crates/openshell-driver-mxc/examples/demo.yaml @@ -3,9 +3,9 @@ # demo.yaml — June 15 MXC filesystem-policy proof. # -# Minimal filesystem policy granting the shared host folder read-write; -# everything else is implicitly denied (default-deny). The granted path MUST -# match `share_dir` / OPENSHELL_MXC_SHARE_DIR and the agent_command target. +# Minimal filesystem policy granting the workload folder read-write. The granted +# path must cover the per-sandbox command executable, working directory, and any +# files that command reads or writes. ProcessContainer denies all ungranted paths. # # NOTE: the canonical OpenShell policy YAML key is `filesystem_policy` # (parsed by the `openshell-policy` crate into SandboxPolicy.filesystem), NOT @@ -19,7 +19,6 @@ filesystem_policy: read_write: - "C:/work/openshell-mxc-demo" # = OPENSHELL_MXC_SHARE_DIR (host-visible share) -# No landlock / process / network_policies for the demo. (Network policy on -# isolation_session is REJECTED by the driver — see the crate README. Adding a -# network_policies block here would make `sandbox create` fail with a precise -# invalid_argument naming the rule, never a silent drop.) +# No landlock, process, or network policy is present. MXC rejects every network +# policy at create time until an enforcing egress path is available; it never +# silently drops network rules. diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/network-reject.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/network-reject.yaml index 47844069b9..e5529eaeb6 100644 --- a/crates/openshell-driver-mxc/examples/e2e-policies/network-reject.yaml +++ b/crates/openshell-driver-mxc/examples/e2e-policies/network-reject.yaml @@ -3,10 +3,9 @@ # # network-reject.yaml — Network policy rejection scenario. # -# A filesystem grant plus a network_policies rule. On isolation_session the -# driver rejects sandbox create at map time with invalid_argument naming the -# network rule (M1 not yet landed). On processcontainer with egress_proxy -# disabled, network policy is also rejected. +# A filesystem grant plus a network_policies rule. The driver rejects sandbox +# create at map time with invalid_argument naming the network rule on every +# backend until MXC has a bound, enforcing egress path. # # This scenario requires NO live backend — it passes even on this box and in # mock mode because the rejection happens in the policy mapper before wxc-exec diff --git a/crates/openshell-driver-mxc/examples/mxc-gateway.toml b/crates/openshell-driver-mxc/examples/mxc-gateway.toml index fd266b197b..4fbd2c41f5 100644 --- a/crates/openshell-driver-mxc/examples/mxc-gateway.toml +++ b/crates/openshell-driver-mxc/examples/mxc-gateway.toml @@ -1,79 +1,29 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# MXC gateway config for the June 15 demo. +# MXC gateway runtime configuration. # -# Pass to the gateway with `--config ` (or the gateway's config-file -# discovery). The `[openshell.drivers.mxc]` table is deserialized into -# `MxcComputeConfig`. -# -# Keep `share_dir`, `agent_cwd`, the agent_command target path, and demo.yaml's -# `filesystem_policy.read_write` entry IDENTICAL, or the positive proof will not -# line up. -# -# The recommended agent is the packaged `mxc-demo-agent.exe` test application: it -# runs BOTH the positive (in-policy) write and the negative (out-of-policy, -# expected-denied) write in a single run and exits 0 only when policy is enforced -# correctly. The runbook copies it into `share_dir` so it is mapped into the -# sandbox and launchable from inside. +# Commands, working directories, and environment variables are sandbox-scoped. +# Supply command and cwd through --driver-config-json when creating a sandbox, +# and environment variables through the standard sandbox --env option. [openshell.drivers.mxc] -# Path to wxc-exec.exe — REQUIRED for live runs on the demo box. Leave commented -# for mock-mode smoke tests (set OPENSHELL_MXC_MOCK_WXC=1 instead). +# Path to wxc-exec.exe. Required for live runs. Leave the default for mock-mode +# smoke tests (set OPENSHELL_MXC_MOCK_WXC=1 instead). wxc_exec_path = "C:\\mxc\\wxc-exec.exe" -# Backend to target: -# "isolation_session" (default) - persistent session; grant-only filesystem -# policy (NO default-deny: a write to an ungranted path may still succeed). -# "process_container" - one-shot AppContainer; genuinely default-deny (a write -# to any ungranted path is denied by the OS). No persistent session. +# process_container is the default because AppContainer enforces default-deny +# filesystem access. isolation_session is an explicit grant-only compatibility +# mode and does not deny access to paths omitted from the sandbox policy. backend = "process_container" -# process_container only: request a Less-Privileged AppContainer (stricter). +# process_container only: request a Less-Privileged AppContainer. # pc_least_privilege = false -# process_container only: AppContainer capabilities to grant (e.g. internetClient). +# process_container only: AppContainer capabilities to grant. # pc_capabilities = [] -# MXC configurationId for isolation session. Never use "small" (known OS bug). +# isolation_session only. Never use "small" (known OS bug). default_configuration_id = "composable" -# Host folder mapped read-write into the sandbox; where hello.txt appears. -share_dir = "C:/work/openshell-mxc-demo" - -# Working directory for the agent inside the sandbox (defaults to share_dir). -agent_cwd = "C:/work/openshell-mxc-demo" - -# Environment variables injected into the agent process inside the sandbox -# (MXC process.env). Each entry is either: -# "KEY=VALUE" - passed through verbatim, or -# "KEY" - resolved from the GATEWAY HOST environment at launch. -# Use the bare-"KEY" form for secrets (e.g. inference API keys): set the value -# on the gateway host (e.g. $env:NV_API_KEY) and reference it by name so it -# never lands in this file. Unset host vars are skipped with a warning. -# agent_env = [ -# "NV_API_KEY", # resolved from the gateway host env -# "OPENAI_BASE_URL=https://integrate.api.nvidia.com/v1", -# ] - -# The agent the driver execs (exec-in-driver). Runs mxc-demo-agent.exe from the -# mapped share folder; the test app performs the positive (in-policy) write + -# read-back AND the negative (out-of-policy) write, then writes its verdict to -# `mxc-demo-agent-result.txt` in the share (host-visible). Args are optional and -# default to the demo paths; passed explicitly here so they track this config. -agent_command = [ - "C:/work/openshell-mxc-demo/mxc-demo-agent.exe", - "C:/work/openshell-mxc-demo", - "C:/Windows/Temp/openshell-mxc-out-of-policy.txt", -] - -# Fallback (no test app): one-liner that only drives the POSITIVE proof. Swap the -# target to an out-of-policy path to manually drive the NEGATIVE proof. -# agent_command = [ -# "powershell", -# "-NoProfile", -# "-Command", -# "Set-Content -Path 'C:/work/openshell-mxc-demo/hello.txt' -Value 'hello from mxc'", -# ] - # Enable --debug on wxc-exec invocations. debug = false diff --git a/crates/openshell-driver-mxc/examples/policy-to-mxc.rs b/crates/openshell-driver-mxc/examples/policy-to-mxc.rs deleted file mode 100644 index 3f90d951a7..0000000000 --- a/crates/openshell-driver-mxc/examples/policy-to-mxc.rs +++ /dev/null @@ -1,454 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Dev/ops example: map `OpenShell` policy YAML to a coarse MXC `ContainerConfig`. -//! -//! Reuses the canonical `openshell_policy::parse_sandbox_policy` parser and the -//! embedded mapper re-exported from this crate. Windows-only: the embedded -//! mapper API only exists on Windows, so on other platforms this compiles to a -//! no-op `main`. -//! -//! The optional MXC JSON-schema validation path (`--schema` + the `jsonschema` -//! dependency) from the original standalone CLI is **dropped** here to keep the -//! example dep-light. Re-add it behind a feature if in-crate parity validation -//! is ever wanted. - -#[cfg(target_os = "windows")] -fn main() -> anyhow::Result<()> { - imp::run() -} - -#[cfg(not(target_os = "windows"))] -fn main() {} - -#[cfg(target_os = "windows")] -mod imp { - use std::net::SocketAddr; - use std::path::{Path, PathBuf}; - - use anyhow::{Context, Result, anyhow, bail}; - use clap::Parser; - use openshell_driver_mxc::{ - DEFAULT_COMMAND, DEFAULT_CONTAINMENT, DEFAULT_MXC_VERSION, LossItem, MxcMappingOptions, - build_loss_report, map_to_mxc, render_readme, split_policy, - }; - use serde_json::Value; - - #[derive(Parser)] - #[command(about = "Map OpenShell policy YAML to a coarse MXC ContainerConfig JSON")] - struct Args { - /// `OpenShell` policy YAML to map. - #[arg(long)] - policy: Option, - - /// Convert every `*policy*.yaml` file found recursively under this dir. - #[arg(long)] - examples_root: Option, - - /// Output directory for a single `--policy` conversion. - #[arg(long, default_value = "converted/single")] - out_dir: PathBuf, - - /// Output root for `--examples-root` conversions. - #[arg(long, default_value = "converted")] - converted_root: PathBuf, - - #[arg(long, default_value = DEFAULT_MXC_VERSION)] - mxc_version: String, - - #[arg(long, default_value = DEFAULT_CONTAINMENT)] - containment: String, - - #[arg(long, default_value = DEFAULT_COMMAND)] - command: String, - - #[arg(long)] - container_id: Option, - - #[arg(long)] - cwd: Option, - - /// `KEY=VALUE` environment variables. - #[arg(long = "env", action = clap::ArgAction::Append)] - env: Vec, - - #[arg(long, default_value_t = 0)] - timeout_ms: u64, - - /// Fail when a lossy mapping (any `error` item) would be emitted. - #[arg(long)] - strict: bool, - - /// Emit `OpenShell` wildcard hosts into `allowedHosts` despite lossiness. - #[arg(long)] - allow_wildcards: bool, - - /// Run the lossless split instead of the coarse map. - /// - /// Requires `--proxy-addr`. Prints and writes the MXC config (with proxy - /// redirect and empty `allowedHosts`) plus the trimmed proxy policy. - #[arg(long)] - split: bool, - - /// Loopback address the `OpenShell` CONNECT proxy listens on. Accepts - /// `IP:PORT`, or a bare port shorthand expanded to `127.0.0.1:PORT`. - #[arg(long)] - proxy_addr: Option, - - /// Deprecated shorthand for `--proxy-addr 127.0.0.1:PORT`. - #[arg(long)] - proxy_port: Option, - } - - pub fn run() -> Result<()> { - let args = Args::parse(); - - if args.split { - let proxy_addr = parse_proxy_addr(args.proxy_addr.as_deref(), args.proxy_port)?; - let policy_path = args - .policy - .as_ref() - .ok_or_else(|| anyhow!("--policy is required with --split"))?; - let stem = policy_path - .file_stem() - .unwrap_or_default() - .to_string_lossy() - .into_owned(); - let slug = args.container_id.clone().unwrap_or(stem); - let mut opts = build_options(&args, &slug); - opts.proxy_redirect = Some(proxy_addr); - return show_split(policy_path, &args.out_dir, &opts); - } - - if let Some(examples_root) = &args.examples_root { - let mut examples = discover_example_policies(examples_root)?; - if examples.is_empty() { - bail!( - "No policy YAML files found under {}", - examples_root.display() - ); - } - examples.sort(); - let count = examples.len(); - for policy_path in &examples { - let slug = slug_for_policy(examples_root, policy_path); - let out_dir = args.converted_root.join(&slug); - let options = build_options(&args, &slug); - convert_policy(policy_path, &out_dir, &options, args.strict)?; - } - println!( - "Converted {count} policy file(s) into {}", - args.converted_root.display() - ); - return Ok(()); - } - - let policy = args - .policy - .as_ref() - .ok_or_else(|| anyhow!("Provide --policy or --examples-root"))?; - let stem = policy - .file_stem() - .unwrap_or_default() - .to_string_lossy() - .into_owned(); - let slug = args.container_id.clone().unwrap_or(stem); - let options = build_options(&args, &slug); - convert_policy(policy, &args.out_dir, &options, args.strict)?; - println!( - "Converted {} into {}", - policy.display(), - args.out_dir.display() - ); - Ok(()) - } - - fn build_options(args: &Args, slug: &str) -> MxcMappingOptions { - let container_id = args - .container_id - .clone() - .unwrap_or_else(|| sanitize_container_id(&format!("openshell-{slug}"))); - MxcMappingOptions { - mxc_version: args.mxc_version.clone(), - containment: args.containment.clone(), - command: args.command.clone(), - container_id, - cwd: args.cwd.clone(), - env: args.env.clone(), - timeout_ms: args.timeout_ms, - allow_wildcards: args.allow_wildcards, - proxy_redirect: None, - } - } - - fn parse_proxy_addr(addr: Option<&str>, port: Option) -> Result { - let Some(raw) = addr else { - let port = port.ok_or_else(|| anyhow!("--proxy-addr is required with --split"))?; - return format!("127.0.0.1:{port}") - .parse() - .context("parsing deprecated --proxy-port shorthand"); - }; - let expanded = if raw.chars().all(|c| c.is_ascii_digit()) { - format!("127.0.0.1:{raw}") - } else { - raw.to_owned() - }; - expanded - .parse() - .with_context(|| format!("parsing --proxy-addr {raw:?}")) - } - - fn convert_policy( - policy_path: &Path, - out_dir: &Path, - options: &MxcMappingOptions, - strict: bool, - ) -> Result<()> { - let content = std::fs::read_to_string(policy_path) - .with_context(|| format!("reading {}", policy_path.display()))?; - let policy = openshell_policy::parse_sandbox_policy(&content) - .map_err(|e| anyhow!("parsing {}: {e}", policy_path.display()))?; - - let result = map_to_mxc(&policy, options); - let error_count = result.loss.iter().filter(|i| i.severity == "error").count(); - - write_outputs(policy_path, out_dir, options, &result.config, &result.loss)?; - - if strict && error_count > 0 { - bail!( - "Strict mapping failed for {}: {error_count} error(s)", - policy_path.display() - ); - } - Ok(()) - } - - fn write_outputs( - policy_path: &Path, - out_dir: &Path, - options: &MxcMappingOptions, - config: &Value, - items: &[LossItem], - ) -> Result<()> { - std::fs::create_dir_all(out_dir) - .with_context(|| format!("creating output dir {}", out_dir.display()))?; - - let config_path = out_dir.join("mxc-config.json"); - let report_path = out_dir.join("loss-report.json"); - let readme_path = out_dir.join("README.md"); - - let config_json = serde_json::to_string_pretty(config).context("serializing mxc config")?; - std::fs::write(&config_path, format!("{config_json}\n")) - .with_context(|| format!("writing {}", config_path.display()))?; - - let report = build_loss_report( - &policy_path.display().to_string(), - &config_path.display().to_string(), - items, - &[], - &options.mxc_version, - &options.containment, - None, - ); - let report_json = - serde_json::to_string_pretty(&report).context("serializing loss report")?; - std::fs::write(&report_path, format!("{report_json}\n")) - .with_context(|| format!("writing {}", report_path.display()))?; - - let readme = render_readme(&policy_path.display().to_string(), &report, config); - std::fs::write(&readme_path, readme) - .with_context(|| format!("writing {}", readme_path.display()))?; - - Ok(()) - } - - // ----------------------------------------------------------------------- - // Lossless-split display - // ----------------------------------------------------------------------- - - fn show_split(policy_path: &Path, out_dir: &Path, opts: &MxcMappingOptions) -> Result<()> { - let content = std::fs::read_to_string(policy_path) - .with_context(|| format!("reading {}", policy_path.display()))?; - let policy = openshell_policy::parse_sandbox_policy(&content) - .map_err(|e| anyhow!("parsing {}: {e}", policy_path.display()))?; - - let result = split_policy(&policy, opts) - .ok_or_else(|| anyhow!("split_policy returned None; is --proxy-addr set?"))?; - write_split_outputs(policy_path, out_dir, opts, &result)?; - - println!("=== MXC ContainerConfig (filesystem + proxy redirect) ==="); - println!("{}", serde_json::to_string_pretty(&result.mxc_config)?); - - println!(); - println!("=== Proxy policy (preserved for OpenShell fine-grained enforcement) ==="); - if result.proxy_policy.network_policies.is_empty() { - println!(" (no network_policies — nothing for the proxy to enforce)"); - } else { - let mut rules: Vec<_> = result.proxy_policy.network_policies.iter().collect(); - rules.sort_by_key(|(k, _)| k.as_str()); - for (name, rule) in rules { - println!(" rule: {name}"); - for ep in &rule.endpoints { - let ports = if ep.ports.is_empty() { - String::new() - } else { - format!( - ":{}", - ep.ports - .iter() - .map(|p| p.to_string()) - .collect::>() - .join(",") - ) - }; - println!( - " endpoint: {}{} protocol={} tls={} access={} enforcement={}", - ep.host, - ports, - if ep.protocol.is_empty() { - "-" - } else { - &ep.protocol - }, - if ep.tls.is_empty() { "-" } else { &ep.tls }, - if ep.access.is_empty() { - "-" - } else { - &ep.access - }, - if ep.enforcement.is_empty() { - "-" - } else { - &ep.enforcement - }, - ); - if !ep.rules.is_empty() { - println!(" allow rules: {}", ep.rules.len()); - } - if !ep.deny_rules.is_empty() { - println!(" deny rules: {}", ep.deny_rules.len()); - } - } - let binaries: Vec<_> = rule.binaries.iter().map(|b| b.path.as_str()).collect(); - if !binaries.is_empty() { - println!(" binaries: {}", binaries.join(", ")); - } - } - } - - if !result.loss.is_empty() { - println!(); - println!( - "=== Filesystem loss items ({} item(s)) ===", - result.loss.len() - ); - for item in &result.loss { - println!(" [{}] {}: {}", item.severity, item.path, item.message); - } - } - - Ok(()) - } - - fn write_split_outputs( - policy_path: &Path, - out_dir: &Path, - options: &MxcMappingOptions, - result: &openshell_driver_mxc::SplitPolicyResult, - ) -> Result<()> { - write_outputs( - policy_path, - out_dir, - options, - &result.mxc_config, - &result.loss, - )?; - let trimmed_path = out_dir.join("trimmed-policy.yaml"); - let trimmed = openshell_policy::serialize_sandbox_policy(&result.proxy_policy) - .map_err(|e| anyhow!("serializing trimmed proxy policy: {e}"))?; - std::fs::write(&trimmed_path, trimmed) - .with_context(|| format!("writing {}", trimmed_path.display()))?; - Ok(()) - } - - // ----------------------------------------------------------------------- - // File discovery & slug helpers - // ----------------------------------------------------------------------- - - fn discover_example_policies(root: &Path) -> Result> { - let mut results = Vec::new(); - collect_policies(root, &mut results)?; - Ok(results) - } - - fn collect_policies(dir: &Path, results: &mut Vec) -> Result<()> { - for entry in - std::fs::read_dir(dir).with_context(|| format!("reading dir {}", dir.display()))? - { - let path = entry?.path(); - if path.is_dir() { - collect_policies(&path, results)?; - } else if let Some(name) = path.file_name().and_then(|n| n.to_str()) - && name.contains("policy") - && path - .extension() - .is_some_and(|e| e.eq_ignore_ascii_case("yaml")) - { - results.push(path); - } - } - Ok(()) - } - - fn slug_for_policy(root: &Path, policy_path: &Path) -> String { - const STANDARD_NAMES: &[&str] = - &["policy.yaml", "sandbox-policy.yaml", "policy.template.yaml"]; - - let relative = policy_path.strip_prefix(root).unwrap_or(policy_path); - let parts: Vec = relative - .components() - .map(|c| c.as_os_str().to_string_lossy().into_owned()) - .collect(); - let filename = policy_path - .file_name() - .unwrap_or_default() - .to_string_lossy(); - - let use_parts: &[String] = if STANDARD_NAMES.contains(&filename.as_ref()) && parts.len() > 1 - { - &parts[..parts.len() - 1] - } else { - &parts - }; - - let joined = if use_parts.is_empty() { - policy_path - .file_stem() - .unwrap_or_default() - .to_string_lossy() - .into_owned() - } else { - use_parts.join("-") - }; - sanitize_container_id(&joined) - } - - /// Replace any character outside `[A-Za-z0-9_.-]` with `-`, collapse runs of - /// `-`, and trim leading/trailing `-`. Hand-rolled to avoid a `regex` dep. - fn sanitize_container_id(raw: &str) -> String { - let mut out = String::with_capacity(raw.len()); - for ch in raw.chars() { - if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '-') { - out.push(ch); - } else if !out.ends_with('-') { - out.push('-'); - } - } - let trimmed = out.trim_matches('-'); - if trimmed.is_empty() { - "openshell-policy".to_owned() - } else { - trimmed.to_owned() - } - } -} diff --git a/crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 b/crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 index 9ea7e776d4..4a20e7b86c 100644 --- a/crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 +++ b/crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 @@ -1,7 +1,10 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -# probe-mxc-host.ps1 - Emit a JSON capability report for this MXC host. +# probe-mxc-host.ps1 - Operator/CI preflight for a prospective MXC host. +# This diagnostic is not invoked by the driver and does not mutate host state. +# It reports OS, wxc-exec, and backend availability so real tests can skip +# unsupported scenarios with an explicit reason. # # PowerShell 5.1-compatible (no && / || / ternary operators). # diff --git a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 index 276e6f5145..a126758923 100644 --- a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 +++ b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # run-mxc-e2e.ps1 - MXC e2e scenario runner. @@ -19,18 +19,18 @@ # powershell -NoProfile -ExecutionPolicy Bypass -File .\run-mxc-e2e.ps1 -Mock # # # Choose backend / filter scenarios: -# .\run-mxc-e2e.ps1 -Backend process_container -Scenario fs-rw-positive-negative +# .\run-mxc-e2e.ps1 -Backend process_container -Scenario fs-rw # # Scenarios & expected verdicts: -# fs-rw-positive-negative - rw grant on DemoDir; in-policy write succeeds. +# fs-rw - rw grant on DemoDir; in-policy write succeeds. # Both backends; skipped when backend not live (non-mock). # fs-readonly - ro grant on a source dir + rw on DemoDir; # write to ro dir should be denied. # Both backends; skipped when backend not live. -# fs-default-deny-empty - empty filesystem policy; every write denied. +# fs-default-deny - empty filesystem policy; every write denied. # processcontainer only (isolation_session has no deny # primitive); skipped on isolation_session. -# network-policy-rejected - rw grant + network_policies rule; +# network-reject - rw grant + network_policies rule; # sandbox create must FAIL (invalid_argument). # Runs on ANY backend including mock — never skips. @@ -181,7 +181,8 @@ function Probe-Backend([string] $backendName, [string] $wxc) { # ── Mode setup ──────────────────────────────────────────────────────────────── -Step "Pre-flight (mode=$(if ($Mock) {'MOCK'} else {'REAL'}), backend=$Backend)" +$mode = if ($Mock) { "MOCK" } else { "REAL" } +Step "Pre-flight (mode=$mode, backend=$Backend)" if ($Mock) { $env:OPENSHELL_MXC_MOCK_WXC = "1" @@ -296,7 +297,7 @@ try { $allScenarios = @( @{ - Name = "fs-rw-positive-negative" + Name = "fs-rw" PolicyFile = Join-Path $policyDir "fs-rw.yaml" Backends = "both" ExpectFail = $false @@ -310,14 +311,14 @@ try { Description = "ro grant + rw share; write to ro dir should be denied" }, @{ - Name = "fs-default-deny-empty" + Name = "fs-default-deny" PolicyFile = Join-Path $policyDir "fs-empty.yaml" Backends = "process_container" ExpectFail = $false Description = "empty filesystem policy; all writes denied (process_container only)" }, @{ - Name = "network-policy-rejected" + Name = "network-reject" PolicyFile = Join-Path $policyDir "network-reject.yaml" Backends = "both" ExpectFail = $true @@ -364,33 +365,30 @@ try { continue } - # Patch the TOML agent_command to a simple one-liner appropriate for - # the scenario. For ExpectFail scenarios the command never runs; for - # others write to DemoDir. + # Build per-sandbox MXC workload config. Commands and working directories + # are create-time inputs, not gateway-wide settings. $target = Join-Path $DemoDir "$($sc.Name)-result.txt" Remove-Item $target -Force -ErrorAction SilentlyContinue $targetFwd = $target.Replace('\', '/') $demoDirFwd = $DemoDir.Replace('\', '/') - $agentCmd = "cmd /c echo ok > `"$targetFwd`"" - $agentCmdJson = "[`"cmd`", `"/c`", `"echo ok > $targetFwd`"]" - - $tomlRuntime = Get-Content $toml -Raw - if ($tomlRuntime -match '(?m)^\s*agent_command\s*=') { - $tomlRuntime = [regex]::Replace($tomlRuntime, '(?ms)^\s*agent_command\s*=.*?(?=\n\s*[^\s\[#]|\n\s*\[|\Z)', "agent_command = $agentCmdJson") - } - if ($tomlRuntime -match '(?m)^\s*share_dir\s*=') { - $tomlRuntime = [regex]::Replace($tomlRuntime, '(?m)^\s*share_dir\s*=.*$', "share_dir = `"$demoDirFwd`"") - } - if ($tomlRuntime -match '(?m)^\s*agent_cwd\s*=') { - $tomlRuntime = [regex]::Replace($tomlRuntime, '(?m)^\s*agent_cwd\s*=.*$', "agent_cwd = `"$demoDirFwd`"") + $driverConfig = @{ + mxc = @{ + command = @("cmd", "/c", "echo.ok>$targetFwd") + cwd = $demoDirFwd + } + } | ConvertTo-Json -Compress -Depth 4 + # Windows PowerShell 5.1 removes embedded quotes when it builds the + # native command line. Escape them so the CLI receives valid JSON. + $driverConfigArg = if ($PSVersionTable.PSVersion.Major -lt 7) { + $driverConfig.Replace('"', '\"') + } else { + $driverConfig } - Set-Content $toml -Value $tomlRuntime -Encoding UTF8 - # Run sandbox create. $createOut = $null $createExitCode = 0 try { - $createOut = & $cli sandbox create --name $sc.Name --policy $sc.PolicyFile --no-tty -- exit 2>&1 + $createOut = & $cli sandbox create --name $sc.Name --policy $sc.PolicyFile --driver-config-json $driverConfigArg --no-tty 2>&1 $createExitCode = $LASTEXITCODE } catch { $createOut = $_.Exception.Message @@ -404,7 +402,7 @@ try { # Evaluate. if ($sc.ExpectFail) { - # network-policy-rejected: create must fail. + # network-reject: create must fail. if ($createExitCode -ne 0) { Ok "$($sc.Name): create correctly failed (exit $createExitCode)" Info "output: $createOutStr" diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index d4791861e3..8b33b27c92 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -4,8 +4,8 @@ //! MXC compute backend: lifecycle logic, in-memory registry, exec-in-driver, //! and self-reported readiness. -use crate::mxc::{MxcFilesystem, MxcNetwork, MxcProcess, MxcProcessContainer, WxcExecInvoker}; -use crate::policy::{EmbeddedPolicyMapper, MapCtx, PolicyMapper}; +use crate::mxc::{MxcFilesystem, MxcProcess, MxcProcessContainer, WxcExecInvoker}; +use crate::policy::{EmbeddedPolicyMapper, MapCtx, MappedConfig, PolicyMapper}; use futures::Stream; use openshell_core::gpu::{driver_gpu_requirements, effective_driver_gpu_count}; use openshell_core::proto::SandboxPolicy; @@ -14,13 +14,13 @@ use openshell_core::proto::compute::v1::{ GetCapabilitiesResponse, WatchSandboxesDeletedEvent, WatchSandboxesEvent, WatchSandboxesPlatformEvent, WatchSandboxesSandboxEvent, watch_sandboxes_event, }; +use openshell_core::proto_struct::struct_to_json_value; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use std::net::SocketAddr; use std::pin::Pin; use std::sync::Arc; -use tokio::process::Child; -use tokio::sync::{Mutex, broadcast, mpsc}; +use tokio::sync::{Mutex, broadcast, mpsc, watch}; +use tokio::task::JoinHandle; use tokio_stream::wrappers::ReceiverStream; use tracing::{info, warn}; @@ -28,22 +28,22 @@ const DRIVER_NAME: &str = "mxc"; const DRIVER_VERSION: &str = env!("CARGO_PKG_VERSION"); /// Sentinel image name — MXC has no OCI image; this string must be non-empty /// so the gateway's `default_image` cache is satisfied, but it is not pullable. -const DEFAULT_IMAGE_SENTINEL: &str = "mxc:isolation-session"; +const DEFAULT_IMAGE_SENTINEL: &str = "mxc:process-container"; // ── Config ──────────────────────────────────────────────────────────────────── /// Which MXC backend the driver targets. /// -/// - `IsolationSession` (default): persistent, attachable session +/// - `IsolationSession`: persistent, attachable session /// (provision → start → exec → stop → deprovision). Grant-only filesystem /// policy — it has no deny primitive and is NOT default-deny. -/// - `ProcessContainer`: one-shot AppContainer. Genuinely default-deny: a +/// - `ProcessContainer` (default): one-shot `AppContainer`. Genuinely default-deny: a /// write to any ungranted path is denied by the OS. No persistent session. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum MxcBackend { - #[default] IsolationSession, + #[default] ProcessContainer, } @@ -52,45 +52,20 @@ pub enum MxcBackend { /// Loaded from `[openshell.drivers.mxc]` in the gateway TOML file, or from /// environment variables / CLI flags via the standard gateway precedence chain. #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] +#[serde(default, deny_unknown_fields)] pub struct MxcComputeConfig { /// Path to `wxc-exec.exe`. Required for live runs. pub wxc_exec_path: String, - /// Backend to target. Default: `isolation_session`. + /// Backend to target. Default: `process_container`. pub backend: MxcBackend, - /// `processContainer` only: request a Less-Privileged AppContainer. + /// `processContainer` only: request a Less-Privileged `AppContainer`. pub pc_least_privilege: bool, - /// `processContainer` only: AppContainer capabilities to grant. + /// `processContainer` only: `AppContainer` capabilities to grant. pub pc_capabilities: Vec, /// MXC `configurationId` for isolation session. Default: `"composable"`. /// Never use `"small"` (known OS bug). pub default_configuration_id: String, - /// Agent command executed inside the sandbox (exec-in-driver). - /// For the June 15 demo this writes `hello.txt`; a follow-up skill - /// swaps in a richer agent. Must be non-empty for `CreateSandbox` to - /// succeed. - pub agent_command: Vec, - /// Working directory for the agent command inside the sandbox. - pub agent_cwd: String, - /// Environment variables injected into the agent process inside the sandbox - /// (becomes MXC `process.env`). Each entry is either: - /// - `KEY=VALUE` — passed through verbatim, or - /// - `KEY` — resolved from the gateway host's environment at launch. - /// The bare-`KEY` form keeps secrets (e.g. inference API keys) out of the - /// config file: set the value on the gateway host and reference it by name. - /// Host vars that are not set are skipped with a warning. - pub agent_env: Vec, - /// Host directory mapped into the sandbox as a read-write grant. - /// Appears in the shared host folder for the positive-proof artifact. - pub share_dir: String, - /// Enable Pattern-C governed egress. When true, MXC receives filesystem - /// grants plus a `network.proxy` redirect and the host CONNECT proxy - /// receives a trimmed network-only policy. - pub egress_proxy: bool, - /// Loopback `IP:PORT` used for MXC `network.proxy` while governed egress is - /// enabled. Per-sandbox allocation is a follow-up; this is one configured - /// address for the initial integration. - pub egress_proxy_addr: String, + /// Enable `--debug` flag on `wxc-exec` invocations. pub debug: bool, } @@ -103,17 +78,22 @@ impl Default for MxcComputeConfig { pc_least_privilege: false, pc_capabilities: Vec::new(), default_configuration_id: crate::mxc::DEFAULT_CONFIGURATION_ID.into(), - agent_command: Vec::new(), - agent_cwd: String::new(), - agent_env: Vec::new(), - share_dir: String::new(), - egress_proxy: false, - egress_proxy_addr: String::new(), + debug: false, } } } +/// Per-sandbox MXC workload settings supplied through +/// `template.driver_config.mxc` / `--driver-config-json`. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct MxcSandboxConfig { + command: Vec, + #[serde(default)] + cwd: String, +} + // ── Registry entry ──────────────────────────────────────────────────────────── #[derive(Debug, Clone, PartialEq, Eq)] @@ -127,10 +107,12 @@ pub enum PhaseState { struct SandboxEntry { sandbox: DriverSandbox, iso_sandbox_id: Option, + isolation_stopped: bool, phase_state: PhaseState, - exec_child: Option, - trimmed_policy: Option, - proxy_addr: Option, + /// Serializes stop/delete with provisioning and process launch. + lifecycle_gate: Arc>, + monitor_cancel: Option>, + monitor_task: Option>, } impl std::fmt::Debug for SandboxEntry { @@ -138,8 +120,8 @@ impl std::fmt::Debug for SandboxEntry { f.debug_struct("SandboxEntry") .field("sandbox_id", &self.sandbox.id) .field("iso_sandbox_id", &self.iso_sandbox_id) + .field("isolation_stopped", &self.isolation_stopped) .field("phase_state", &self.phase_state) - .field("proxy_addr", &self.proxy_addr) .finish_non_exhaustive() } } @@ -212,63 +194,79 @@ impl std::fmt::Debug for MxcComputeBackend { } } -fn configured_egress_addr(config: &MxcComputeConfig) -> Result, tonic::Status> { - if !config.egress_proxy { - return Ok(None); - } - if config.backend == MxcBackend::IsolationSession { - return Err(tonic::Status::invalid_argument( - "mxc governed egress requires process_container; network.proxy is not supported on isolation_session until MXC M1 lands", - )); - } - let raw = config.egress_proxy_addr.trim(); - if raw.is_empty() { +fn sandbox_config(sandbox: &DriverSandbox) -> Result { + let config = sandbox + .spec + .as_ref() + .and_then(|spec| spec.template.as_ref()) + .and_then(|template| template.driver_config.as_ref()) + .ok_or_else(|| { + tonic::Status::invalid_argument( + "mxc requires template.driver_config.mxc with a non-empty command array", + ) + })?; + let config: MxcSandboxConfig = + serde_json::from_value(struct_to_json_value(config)).map_err(|error| { + tonic::Status::invalid_argument(format!("invalid mxc driver_config: {error}")) + })?; + if config.command.is_empty() || config.command[0].is_empty() { return Err(tonic::Status::invalid_argument( - "mxc egress_proxy_addr is required when egress_proxy is enabled", + "mxc driver_config.command must contain a non-empty executable", )); } - let addr = raw.parse::().map_err(|e| { - tonic::Status::invalid_argument(format!( - "mxc egress_proxy_addr must be an IP:PORT socket address: {e}" - )) - })?; - // MXC 0.6.0-alpha expresses the redirect as {"proxy": {"localhost": N}} - // — it has no way to encode a non-loopback host. Reject early so the - // operator gets a clear message rather than a silent policy gap. - // Use 127.0.0.1:PORT. Future schema versions may lift this restriction. - if addr.ip() != std::net::IpAddr::from([127, 0, 0, 1]) { - return Err(tonic::Status::invalid_argument(format!( - "mxc egress_proxy_addr must be 127.0.0.1:PORT — MXC 0.6.0-alpha \ - expresses the redirect as {{\"localhost\": N}} and cannot encode \ - non-loopback addresses (got {})", - addr.ip() - ))); - } - Ok(Some(addr)) + Ok(config) } -/// Resolve configured `agent_env` entries into concrete `KEY=VALUE` strings for -/// MXC `process.env`. `KEY=VALUE` entries pass through verbatim; bare `KEY` -/// entries are looked up in the gateway host environment so secrets never have -/// to live in the config file. Unset host vars are skipped with a warning. -fn resolve_agent_env(entries: &[String]) -> Vec { - let mut resolved = Vec::with_capacity(entries.len()); - for entry in entries { - if entry.contains('=') { - resolved.push(entry.clone()); - continue; - } - match std::env::var(entry) { - Ok(value) => resolved.push(format!("{entry}={value}")), - Err(_) => warn!( - var = %entry, - "agent_env passthrough variable not set in gateway host environment; skipping" - ), +fn sandbox_environment(sandbox: &DriverSandbox) -> Vec { + let Some(spec) = sandbox.spec.as_ref() else { + return Vec::new(); + }; + let mut environment = spec + .template + .as_ref() + .map_or_else(HashMap::new, |template| template.environment.clone()); + environment.extend(spec.environment.clone()); + let mut environment = environment + .into_iter() + .map(|(key, value)| format!("{key}={value}")) + .collect::>(); + environment.sort_unstable(); + environment +} + +fn encode_windows_command_line(args: &[String]) -> String { + args.iter() + .map(|arg| quote_windows_argument(arg)) + .collect::>() + .join(" ") +} + +fn quote_windows_argument(arg: &str) -> String { + if !arg.is_empty() && !arg.chars().any(|ch| ch.is_whitespace() || ch == '"') { + return arg.to_string(); + } + + let mut quoted = String::from("\""); + let mut backslashes = 0; + for ch in arg.chars() { + match ch { + '\\' => backslashes += 1, + '"' => { + quoted.push_str(&"\\".repeat(backslashes * 2 + 1)); + quoted.push('"'); + backslashes = 0; + } + _ => { + quoted.push_str(&"\\".repeat(backslashes)); + backslashes = 0; + quoted.push(ch); + } } } - resolved + quoted.push_str(&"\\".repeat(backslashes * 2)); + quoted.push('"'); + quoted } - impl MxcComputeBackend { pub fn new(config: MxcComputeConfig) -> Self { let invoker = WxcExecInvoker::new(&config.wxc_exec_path, config.debug); @@ -278,8 +276,8 @@ impl MxcComputeBackend { config, registry: Arc::new(Mutex::new(HashMap::new())), watch_tx: Arc::new(watch_tx), - // Primary impl: the embedded mapper (Giedrius's logic vendored into - // `policy_map`). Swap for `StubPolicyMapper` only for scaffolding. + // Production policy translation is always handled by the embedded + // mapper before any MXC lifecycle side effects begin. policy_mapper: Arc::new(EmbeddedPolicyMapper), pending_policies: Arc::new(Mutex::new(HashMap::new())), } @@ -320,23 +318,17 @@ impl MxcComputeBackend { "mxc driver does not support GPU sandboxes", )); } - if let Some(tmpl) = &spec.template { - if !tmpl.agent_socket_path.is_empty() { - return Err(tonic::Status::invalid_argument( - "mxc driver does not support agent_socket_path (no in-sandbox supervisor)", - )); - } + if let Some(tmpl) = &spec.template + && !tmpl.agent_socket_path.is_empty() + { + return Err(tonic::Status::invalid_argument( + "mxc driver does not support agent_socket_path (no in-sandbox supervisor)", + )); } } - if self.config.agent_command.is_empty() { - return Err(tonic::Status::invalid_argument( - "mxc driver: agent_command is required in [openshell.drivers.mxc]", - )); - } - configured_egress_addr(&self.config)?; + sandbox_config(sandbox)?; Ok(()) } - pub async fn get_sandbox(&self, sandbox_name: &str) -> Option { let registry = self.registry.lock().await; registry @@ -354,20 +346,29 @@ impl MxcComputeBackend { let sandbox_id = sandbox.id.clone(); // Consume the out-of-band policy staged by `ComputeRuntime::create_sandbox` - // (A1). Always remove — even on the early-return paths below — so nothing - // leaks if validation or the duplicate check rejects the create. + // (A1). Always remove so rejected creates cannot leak policy state. let policy = self.pending_policies.lock().await.remove(&sandbox_id); - self.validate_sandbox_create(sandbox)?; + let sandbox_config = sandbox_config(sandbox)?; + + // Policy translation is deterministic and side-effect free. Do it before + // inserting the registry entry or launching MXC so invalid requests fail + // synchronously at the CreateSandbox boundary. + let mapped = self + .policy_mapper + .map( + policy.as_ref(), + &MapCtx { + sandbox_id: sandbox_id.clone(), + egress: None, + }, + ) + .map_err(|error| tonic::Status::invalid_argument(error.to_string()))?; - // `sandbox_token` is minted by the gateway only when the sandbox-JWT - // issuer is configured. On MXC there is no in-sandbox supervisor that - // would ever consume it (the supervisor-removal design — D1/D4), so an - // absent token must not block create. Log it and move on. if sandbox .spec .as_ref() - .map_or(true, |s| s.sandbox_token.is_empty()) + .is_none_or(|spec| spec.sandbox_token.is_empty()) { tracing::debug!( sandbox = %sandbox.name, @@ -376,7 +377,11 @@ impl MxcComputeBackend { } let sandbox_name = sandbox.name.clone(); - + let lifecycle_gate = Arc::new(Mutex::new(())); + // Take the gate before publishing the entry. stop/delete can discover the + // sandbox immediately, but cannot pass this guard until startup has either + // installed a cancellable child monitor or failed. + let startup_guard = lifecycle_gate.clone().lock_owned().await; { let mut registry = self.registry.lock().await; if registry.contains_key(&sandbox_id) { @@ -401,10 +406,11 @@ impl MxcComputeBackend { SandboxEntry { sandbox: initial, iso_sandbox_id: None, + isolation_stopped: false, phase_state: PhaseState::Starting, - exec_child: None, - trimmed_policy: None, - proxy_addr: None, + lifecycle_gate, + monitor_cancel: None, + monitor_task: None, }, ); } @@ -413,46 +419,68 @@ impl MxcComputeBackend { let config = self.config.clone(); let registry = self.registry.clone(); let watch_tx = self.watch_tx.clone(); - let policy_mapper = self.policy_mapper.clone(); let sandbox = sandbox.clone(); - tokio::spawn(async move { run_lifecycle( invoker, config, - policy_mapper, registry, watch_tx, sandbox, - policy, + sandbox_config, + mapped, + startup_guard, ) .await; }); Ok(()) } - pub async fn stop_sandbox(&self, sandbox_name: &str) -> Result<(), tonic::Status> { - let (iso_id, sandbox_id) = { + let (sandbox_id, lifecycle_gate) = { let registry = self.registry.lock().await; let entry = registry .values() - .find(|e| e.sandbox.name == sandbox_name) + .find(|entry| entry.sandbox.name == sandbox_name) .ok_or_else(|| { tonic::Status::not_found(format!("sandbox {sandbox_name} not found")) })?; - (entry.iso_sandbox_id.clone(), entry.sandbox.id.clone()) + (entry.sandbox.id.clone(), entry.lifecycle_gate.clone()) }; - if let Some(ref iso_id) = iso_id { - if let Err(e) = self.invoker.stop(iso_id).await { - warn!(sandbox = %sandbox_name, error = %e, "wxc-exec stop failed"); - } + let _lifecycle_guard = lifecycle_gate.lock().await; + let (iso_id, mut isolation_stopped, cancel, monitor_task) = { + let mut registry = self.registry.lock().await; + let entry = registry.get_mut(&sandbox_id).ok_or_else(|| { + tonic::Status::not_found(format!("sandbox {sandbox_name} not found")) + })?; + ( + entry.iso_sandbox_id.clone(), + entry.isolation_stopped, + entry.monitor_cancel.take(), + entry.monitor_task.take(), + ) + }; + if let Some(cancel) = cancel { + let _ = cancel.send(true); + } + if let Some(task) = monitor_task { + task.await.map_err(|error| { + tonic::Status::internal(format!("mxc process monitor failed: {error}")) + })?; + } + if let Some(ref iso_id) = iso_id + && !isolation_stopped + { + self.invoker.stop(iso_id).await.map_err(|error| { + tonic::Status::internal(format!("wxc-exec stop failed: {error}")) + })?; + isolation_stopped = true; } - let watch_tx = self.watch_tx.clone(); let mut registry = self.registry.lock().await; if let Some(entry) = registry.get_mut(&sandbox_id) { + entry.isolation_stopped = isolation_stopped; entry.phase_state = PhaseState::Stopped; entry.sandbox = make_sandbox_with_condition( &entry.sandbox, @@ -467,28 +495,64 @@ impl MxcComputeBackend { ); let snapshot = entry.sandbox.clone(); drop(registry); - let _ = watch_tx.send(sandbox_event(snapshot)); + let _ = self.watch_tx.send(sandbox_event(snapshot)); } Ok(()) } - pub async fn delete_sandbox( &self, sandbox_id: &str, sandbox_name: &str, ) -> Result { - let iso_id = { + let lifecycle_gate = { let registry = self.registry.lock().await; - registry - .get(sandbox_id) - .and_then(|e| e.iso_sandbox_id.clone()) + let Some(entry) = registry.get(sandbox_id) else { + return Ok(false); + }; + if entry.sandbox.name != sandbox_name { + return Err(tonic::Status::failed_precondition( + "sandbox_id did not match sandbox_name", + )); + } + entry.lifecycle_gate.clone() }; - if let Some(iso_id) = iso_id { - let _ = self.invoker.stop(&iso_id).await; - if let Err(e) = self.invoker.deprovision(&iso_id).await { - warn!(sandbox = %sandbox_name, error = %e, "wxc-exec deprovision failed"); + let _lifecycle_guard = lifecycle_gate.lock().await; + let (iso_id, isolation_stopped, cancel, monitor_task) = { + let mut registry = self.registry.lock().await; + let Some(entry) = registry.get_mut(sandbox_id) else { + return Ok(false); + }; + ( + entry.iso_sandbox_id.clone(), + entry.isolation_stopped, + entry.monitor_cancel.take(), + entry.monitor_task.take(), + ) + }; + if let Some(cancel) = cancel { + let _ = cancel.send(true); + } + if let Some(task) = monitor_task { + task.await.map_err(|error| { + tonic::Status::internal(format!("mxc process monitor failed: {error}")) + })?; + } + if let Some(ref iso_id) = iso_id { + if !isolation_stopped { + self.invoker.stop(iso_id).await.map_err(|error| { + tonic::Status::internal(format!("wxc-exec stop failed: {error}")) + })?; + // Persist phase progress before deprovision. If deprovision + // fails, a retry resumes here instead of stopping twice. + let mut registry = self.registry.lock().await; + if let Some(entry) = registry.get_mut(sandbox_id) { + entry.isolation_stopped = true; + } } + self.invoker.deprovision(iso_id).await.map_err(|error| { + tonic::Status::internal(format!("wxc-exec deprovision failed: {error}")) + })?; } let mut registry = self.registry.lock().await; @@ -498,7 +562,6 @@ impl MxcComputeBackend { } Ok(false) } - /// Returns a stream of watch events. /// /// First emits a snapshot of all current sandboxes, then forwards live @@ -507,12 +570,17 @@ impl MxcComputeBackend { let (tx, rx) = mpsc::channel::>(256); - // Send initial snapshots before subscribing so we don't miss live events. - let snapshots: Vec = { + // Subscribe while holding the registry lock. Every transition is then + // represented by either this snapshot or the live receiver. + let (snapshots, mut broadcast_rx): (Vec, _) = { let registry = self.registry.lock().await; - registry.values().map(|e| e.sandbox.clone()).collect() + let broadcast_rx = self.watch_tx.subscribe(); + let snapshots = registry + .values() + .map(|entry| entry.sandbox.clone()) + .collect(); + (snapshots, broadcast_rx) }; - let mut broadcast_rx = self.watch_tx.subscribe(); let tx_clone = tx.clone(); tokio::spawn(async move { @@ -532,7 +600,6 @@ impl MxcComputeBackend { } Err(broadcast::error::RecvError::Lagged(_)) => { // Drop lagged events — the gateway re-syncs via Get/List. - continue; } Err(broadcast::error::RecvError::Closed) => break, } @@ -549,130 +616,104 @@ impl MxcComputeBackend { async fn run_lifecycle( invoker: WxcExecInvoker, config: MxcComputeConfig, - policy_mapper: Arc, registry: Arc>>, watch_tx: Arc>, sandbox: DriverSandbox, - policy: Option, + sandbox_config: MxcSandboxConfig, + mapped: MappedConfig, + _startup_guard: tokio::sync::OwnedMutexGuard<()>, ) { let sandbox_id = sandbox.id.clone(); let sandbox_name = sandbox.name.clone(); - let egress_addr = match configured_egress_addr(&config) { - Ok(addr) => addr, - Err(e) => { - set_failed(®istry, &watch_tx, &sandbox, &sandbox_id, &e.to_string()).await; - return; - } - }; - - // 1. Map policy → MXC filesystem config (A1: policy is now threaded in). - let map_ctx = MapCtx { - sandbox_id: sandbox_id.clone(), - share_dir: if config.share_dir.is_empty() { - None - } else { - Some(config.share_dir.clone()) - }, - egress: egress_addr, - }; - let mapped = match policy_mapper.map(policy.as_ref(), &map_ctx) { - Ok(m) => m, - Err(e) => { - set_failed(®istry, &watch_tx, &sandbox, &sandbox_id, &e.to_string()).await; - return; - } - }; - let trimmed_policy = mapped.trimmed_policy.clone(); - let proxy_addr = mapped.proxy_addr; - if let Some(addr) = proxy_addr { - { - let mut reg = registry.lock().await; - if let Some(entry) = reg.get_mut(&sandbox_id) { - entry.trimmed_policy = trimmed_policy; - entry.proxy_addr = Some(addr); - } - } - let _ = watch_tx.send(platform_event( - sandbox_id.clone(), - "EgressRedirect", - format!("MXC egress redirected to OpenShell host CONNECT proxy at {addr}"), - )); - } - - // 2. Build filesystem grants + the agent process (shared across backends). let filesystem = MxcFilesystem { readwrite_paths: mapped.readwrite_paths, readonly_paths: mapped.readonly_paths, // OpenShell's policy model has no explicit deny field; default-deny is - // implicit. processContainer enforces that at the OS level regardless. + // implicit and enforced by processContainer at the OS boundary. denied_paths: Vec::new(), }; - let command_line = config.agent_command.join(" "); - let cwd = if config.agent_cwd.is_empty() { - config.share_dir.clone() - } else { - config.agent_cwd.clone() - }; + let command_line = encode_windows_command_line(&sandbox_config.command); let process = MxcProcess { command_line: command_line.clone(), - cwd, - env: resolve_agent_env(&config.agent_env), + cwd: sandbox_config.cwd, + env: sandbox_environment(&sandbox), timeout: 0, }; - let network = proxy_addr.map(|addr| MxcNetwork { - default_policy: "block".into(), - proxy: Some(addr), - }); - - // 3. Launch the agent. The backends differ fundamentally: - // - isolation_session: persistent (provision -> start -> exec). - // - processContainer: one-shot (a single ephemeral AppContainer). + let child = match config.backend { MxcBackend::IsolationSession => { let iso_sandbox_id = match invoker - .provision(&config.default_configuration_id, filesystem, network) + .provision(&config.default_configuration_id, filesystem, None) .await { Ok(id) => id, - Err(e) => { - set_failed(®istry, &watch_tx, &sandbox, &sandbox_id, &e.to_string()).await; + Err(error) => { + set_failed( + ®istry, + &watch_tx, + &sandbox, + &sandbox_id, + &error.to_string(), + ) + .await; return; } }; info!(sandbox = %sandbox_name, iso_id = %iso_sandbox_id, "MXC provisioned"); { - let mut reg = registry.lock().await; - if let Some(entry) = reg.get_mut(&sandbox_id) { + let mut registry = registry.lock().await; + if let Some(entry) = registry.get_mut(&sandbox_id) { + // Publish cleanup identity before any later lifecycle await. entry.iso_sandbox_id = Some(iso_sandbox_id.clone()); + entry.isolation_stopped = false; } } - if let Err(e) = invoker.start(&iso_sandbox_id).await { - set_failed(®istry, &watch_tx, &sandbox, &sandbox_id, &e.to_string()).await; + if let Err(error) = invoker.start(&iso_sandbox_id).await { + set_failed( + ®istry, + &watch_tx, + &sandbox, + &sandbox_id, + &error.to_string(), + ) + .await; return; } info!(sandbox = %sandbox_name, "MXC started"); match invoker.spawn_exec(&iso_sandbox_id, process).await { - Ok(c) => c, - Err(e) => { - set_failed(®istry, &watch_tx, &sandbox, &sandbox_id, &e.to_string()).await; + Ok(child) => child, + Err(error) => { + set_failed( + ®istry, + &watch_tx, + &sandbox, + &sandbox_id, + &error.to_string(), + ) + .await; return; } } } MxcBackend::ProcessContainer => { - // One-shot: no provision/start, no persistent iso id. The - // AppContainer is created, runs the agent, and is torn down on exit. - let pc = MxcProcessContainer { + let process_container = MxcProcessContainer { least_privilege: config.pc_least_privilege, capabilities: config.pc_capabilities.clone(), }; match invoker - .run_oneshot(&sandbox_id, filesystem, pc, process, network) + .run_oneshot(&sandbox_id, filesystem, process_container, process, None) .await { - Ok(c) => c, - Err(e) => { - set_failed(®istry, &watch_tx, &sandbox, &sandbox_id, &e.to_string()).await; + Ok(child) => child, + Err(error) => { + set_failed( + ®istry, + &watch_tx, + &sandbox, + &sandbox_id, + &error.to_string(), + ) + .await; return; } } @@ -680,7 +721,6 @@ async fn run_lifecycle( }; info!(sandbox = %sandbox_name, command = %command_line, backend = ?config.backend, "MXC agent launched"); - // 5. Self-report Ready=True. let ready_sandbox = make_sandbox_with_condition( &sandbox, &DriverCondition { @@ -692,24 +732,27 @@ async fn run_lifecycle( }, false, ); + let (cancel_tx, cancel_rx) = watch::channel(false); { - let mut reg = registry.lock().await; - if let Some(entry) = reg.get_mut(&sandbox_id) { + // Publish cancellation state before the monitor can observe a fast + // process exit. Holding the registry lock while spawning prevents a + // completed child from being overwritten with AgentRunning. + let mut registry_guard = registry.lock().await; + if let Some(entry) = registry_guard.get_mut(&sandbox_id) { entry.sandbox = ready_sandbox.clone(); entry.phase_state = PhaseState::Running; - entry.exec_child = Some(child); + entry.monitor_cancel = Some(cancel_tx); + entry.monitor_task = Some(tokio::spawn(monitor_exec( + registry.clone(), + watch_tx.clone(), + sandbox.clone(), + sandbox_id.clone(), + cancel_rx, + child, + ))); } } let _ = watch_tx.send(sandbox_event(ready_sandbox)); - - // 6. Monitor exec completion in background. - let registry2 = registry.clone(); - let watch_tx2 = watch_tx.clone(); - let sandbox2 = sandbox.clone(); - let sandbox_id2 = sandbox_id.clone(); - tokio::spawn(async move { - monitor_exec(registry2, watch_tx2, sandbox2, sandbox_id2).await; - }); } async fn monitor_exec( @@ -717,22 +760,28 @@ async fn monitor_exec( watch_tx: Arc>, sandbox: DriverSandbox, sandbox_id: String, + mut cancel_rx: watch::Receiver, + mut child: tokio::process::Child, ) { - let child = { - let mut reg = registry.lock().await; - reg.get_mut(&sandbox_id).and_then(|e| e.exec_child.take()) - }; - let Some(mut child) = child else { - return; + let status = tokio::select! { + status = child.wait() => status, + changed = cancel_rx.changed() => { + let should_kill = changed.is_ok() && *cancel_rx.borrow_and_update(); + if should_kill { + if let Err(error) = child.kill().await { + warn!(sandbox = %sandbox.name, error = %error, "failed to terminate MXC agent process"); + } + // `kill` waits on current Tokio releases, but an explicit wait is + // harmless and guarantees the OS process handle is reaped. + let _ = child.wait().await; + } + return; + } }; - match child.wait().await { + match status { Ok(status) if status.success() => { info!(sandbox = %sandbox.name, "MXC agent exec completed successfully"); - // A successful one-shot agent (exit 0) must NOT demote the sandbox to - // Error. The isolation session is still alive until stop/deprovision, - // and the demo's positive proof is "Ready + in-policy file written". - // Keep Ready=True so derive_phase leaves the public phase at Ready. let done = make_sandbox_with_condition( &sandbox, &DriverCondition { @@ -744,12 +793,12 @@ async fn monitor_exec( }, false, ); - let mut reg = registry.lock().await; - if let Some(entry) = reg.get_mut(&sandbox_id) { + let mut registry = registry.lock().await; + if let Some(entry) = registry.get_mut(&sandbox_id) { entry.sandbox = done.clone(); entry.phase_state = PhaseState::Running; } - drop(reg); + drop(registry); let _ = watch_tx.send(sandbox_event(done)); } Ok(status) => { @@ -771,20 +820,19 @@ async fn monitor_exec( }, false, ); - let mut reg = registry.lock().await; - if let Some(entry) = reg.get_mut(&sandbox_id) { + let mut registry = registry.lock().await; + if let Some(entry) = registry.get_mut(&sandbox_id) { entry.sandbox = failed.clone(); entry.phase_state = PhaseState::Failed(format!("exit code {code}")); } - drop(reg); + drop(registry); let _ = watch_tx.send(sandbox_event(failed)); } - Err(e) => { - warn!(sandbox = %sandbox.name, error = %e, "MXC agent exec wait error"); + Err(error) => { + warn!(sandbox = %sandbox.name, error = %error, "MXC agent exec wait error"); } } } - async fn set_failed( registry: &Arc>>, watch_tx: &Arc>, @@ -848,11 +896,21 @@ fn make_sandbox_with_condition( mod lifecycle_tests { use super::*; use futures::StreamExt; - use openshell_core::proto::compute::v1::DriverSandboxSpec; + use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; use openshell_core::proto::{FilesystemPolicy, SandboxPolicy}; use std::time::Duration; fn driver_sandbox(id: &str) -> DriverSandbox { + driver_sandbox_with_command(id, "", vec!["cmd".into(), "/c".into(), "exit 0".into()]) + } + + fn driver_sandbox_with_command(id: &str, cwd: &str, command: Vec) -> DriverSandbox { + let serde_json::Value::Object(driver_config) = serde_json::json!({ + "command": command, + "cwd": cwd, + }) else { + unreachable!(); + }; DriverSandbox { id: id.to_string(), name: id.to_string(), @@ -860,18 +918,23 @@ mod lifecycle_tests { workspace: String::new(), spec: Some(DriverSandboxSpec { sandbox_token: "test-token".into(), + template: Some(DriverSandboxTemplate { + driver_config: Some( + openshell_core::proto_struct::json_object_to_struct(driver_config).unwrap(), + ), + ..Default::default() + }), ..Default::default() }), status: None, } } - fn fs_policy(read_write: &[&str]) -> SandboxPolicy { SandboxPolicy { filesystem: Some(FilesystemPolicy { include_workdir: false, read_only: Vec::new(), - read_write: read_write.iter().map(|s| s.to_string()).collect(), + read_write: read_write.iter().map(ToString::to_string).collect(), }), ..Default::default() } @@ -896,92 +959,60 @@ mod lifecycle_tests { F: FnMut(&DriverSandbox) -> bool, { for _ in 0..100 { - if let Some(sb) = backend.get_sandbox(name).await { - if pred(&sb) { - return Some(sb); - } + if let Some(sandbox) = backend.get_sandbox(name).await + && pred(&sandbox) + { + return Some(sandbox); } tokio::time::sleep(Duration::from_millis(100)).await; } None } - fn demo_config(share_dir: &str, agent_command: Vec) -> MxcComputeConfig { - MxcComputeConfig { - agent_command, - agent_cwd: share_dir.into(), - share_dir: share_dir.into(), - ..Default::default() - } - } - #[test] - fn mxc_config_defaults_leave_egress_disabled() { - let config = MxcComputeConfig::default(); - assert!(!config.egress_proxy); - assert!(config.egress_proxy_addr.is_empty()); + fn mxc_config_defaults_to_default_deny_process_container() { + assert_eq!( + MxcComputeConfig::default().backend, + MxcBackend::ProcessContainer + ); } #[test] - fn resolve_agent_env_passthrough_and_host_lookup() { - // Literal KEY=VALUE passes through verbatim. + fn sandbox_environment_uses_sandbox_scope_with_spec_precedence() { + let mut sandbox = driver_sandbox("sb-env"); + let spec = sandbox.spec.as_mut().unwrap(); + spec.template + .as_mut() + .unwrap() + .environment + .insert("SHARED".into(), "template".into()); + spec.environment.insert("SHARED".into(), "spec".into()); + spec.environment.insert("TOKEN".into(), "value".into()); assert_eq!( - resolve_agent_env(&["FOO=bar".into()]), - vec!["FOO=bar".to_string()] + sandbox_environment(&sandbox), + vec!["SHARED=spec".to_string(), "TOKEN=value".to_string()] ); - - // Bare KEY for an unset host var is skipped (not emitted empty). - assert!(resolve_agent_env(&["OPENSHELL_TEST_DEFINITELY_UNSET_VAR".into()]).is_empty()); - - // Bare KEY for a set host var resolves to KEY=value from the host env. - // Use PATH (guaranteed present) read-only, so the test never mutates the - // process environment (set_var/remove_var are unsafe + racy under - // parallel test execution in edition 2024). - let var = "PATH"; - let expected = std::env::var(var).expect("PATH must be set in test environment"); - let resolved = resolve_agent_env(&[var.to_string()]); - assert_eq!(resolved, vec![format!("{var}={expected}")]); } #[test] - fn egress_non_loopback_addr_is_rejected() { - // MXC 0.6.0-alpha can only express {"proxy": {"localhost": N}}, so - // non-127.0.0.1 redirect addresses must be rejected at validate time. - let mut config = demo_config( - "C:/work/demo", - vec!["cmd".into(), "/c".into(), "exit 0".into()], + fn windows_command_line_preserves_argument_boundaries() { + assert_eq!( + encode_windows_command_line(&[ + r"C:\Program Files\Agent\agent.exe".into(), + "hello world".into(), + String::new(), + ]), + r#""C:\Program Files\Agent\agent.exe" "hello world" """# ); - config.backend = MxcBackend::ProcessContainer; - config.egress_proxy = true; - config.egress_proxy_addr = "10.0.0.1:18080".into(); - let backend = MxcComputeBackend::new_mocked(config); - let err = backend - .validate_sandbox_create(&driver_sandbox("sb-nonlocal")) - .unwrap_err(); - assert_eq!(err.code(), tonic::Code::InvalidArgument); - assert!( - err.message().contains("127.0.0.1"), - "error should mention 127.0.0.1, got: {}", - err.message() + assert_eq!( + quote_windows_argument(r#"say "hello""#), + r#""say \"hello\"""# ); - } - - #[test] - fn egress_on_isolation_session_is_rejected() { - let mut config = demo_config( - "C:/work/demo", - vec!["cmd".into(), "/c".into(), "exit 0".into()], + assert_eq!( + quote_windows_argument("trailing slash\\ "), + r#""trailing slash\ ""# ); - config.egress_proxy = true; - config.egress_proxy_addr = "127.0.0.1:18080".into(); - let backend = MxcComputeBackend::new_mocked(config); - let err = backend - .validate_sandbox_create(&driver_sandbox("sb-egress-iso")) - .unwrap_err(); - assert_eq!(err.code(), tonic::Code::InvalidArgument); - assert!(err.message().contains("MXC M1")); } - #[tokio::test] async fn positive_in_policy_write_reaches_ready_and_materializes_file() { let tmp = tempfile::tempdir().unwrap(); @@ -993,7 +1024,7 @@ mod lifecycle_tests { "-Command".into(), format!("Set-Content -LiteralPath {hello} -Value hi"), ]; - let backend = MxcComputeBackend::new_mocked(demo_config(&share, cmd)); + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); // Stage the policy via the A1 side channel (as ComputeRuntime would). let sink = backend.policy_sink(); @@ -1001,7 +1032,7 @@ mod lifecycle_tests { .await .insert("sb-pos".into(), fs_policy(&[&share])); - let sb = driver_sandbox("sb-pos"); + let sb = driver_sandbox_with_command("sb-pos", &share, cmd); backend.create_sandbox(&sb).await.expect("create accepted"); // Self-reported Ready=True (no supervisor) once the agent exec launches. @@ -1051,16 +1082,14 @@ mod lifecycle_tests { "-Command".into(), format!("Set-Content -LiteralPath {hello} -Value hi"), ]; - let mut config = demo_config(&share, cmd); - config.backend = MxcBackend::ProcessContainer; - let backend = MxcComputeBackend::new_mocked(config); + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); let sink = backend.policy_sink(); sink.lock() .await .insert("sb-pc".into(), fs_policy(&[&share])); - let sb = driver_sandbox("sb-pc"); + let sb = driver_sandbox_with_command("sb-pc", &share, cmd); backend.create_sandbox(&sb).await.expect("create accepted"); let ready = wait_for(&backend, "sb-pc", |s| { @@ -1092,113 +1121,6 @@ mod lifecycle_tests { ); } - #[tokio::test] - async fn split_path_provisions_with_proxy_redirect() { - use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule}; - - let tmp = tempfile::tempdir().unwrap(); - let share = tmp.path().to_string_lossy().replace('\\', "/"); - let hello = format!("{share}/hello.txt"); - let cmd = vec![ - "powershell".into(), - "-NoProfile".into(), - "-Command".into(), - format!("Set-Content -LiteralPath {hello} -Value hi"), - ]; - let mut config = demo_config(&share, cmd); - config.backend = MxcBackend::ProcessContainer; - config.egress_proxy = true; - config.egress_proxy_addr = "127.0.0.1:18080".into(); - let backend = MxcComputeBackend::new_mocked(config); - let mut stream = backend.watch_sandboxes().await; - - let mut policy = fs_policy(&[&share]); - policy.network_policies.insert( - "api".into(), - NetworkPolicyRule { - name: "api".into(), - endpoints: vec![NetworkEndpoint { - host: "example.com".into(), - ports: vec![443], - protocol: "rest".into(), - ..Default::default() - }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".into(), - ..Default::default() - }], - }, - ); - backend - .policy_sink() - .lock() - .await - .insert("sb-egress".into(), policy.clone()); - - backend - .create_sandbox(&driver_sandbox("sb-egress")) - .await - .expect("create accepted"); - - let ready = wait_for(&backend, "sb-egress", |s| { - ready_condition(s).is_some_and(|c| c.status == "True" && c.reason == "AgentRunning") - }) - .await; - assert!( - ready.is_some(), - "egress split sandbox should reach Ready=True" - ); - - let recorded = crate::mxc::mock_recorded_config("sb-egress").expect("mock recorded config"); - assert_eq!(recorded["network"]["defaultPolicy"], "block"); - assert!( - recorded["network"]["allowedHosts"] - .as_array() - .unwrap() - .is_empty() - ); - // MXC 0.6.0-alpha accepts only {"proxy": {"localhost": N}}. - assert_eq!(recorded["network"]["proxy"]["localhost"], 18080); - assert!( - recorded["network"]["proxy"].get("host").is_none(), - "proxy must not contain 'host' key" - ); - assert!( - recorded["network"]["proxy"].get("port").is_none(), - "proxy must not contain 'port' key" - ); - - let reg = backend.registry.lock().await; - let entry = reg.get("sb-egress").expect("registry entry"); - assert_eq!(entry.proxy_addr, Some("127.0.0.1:18080".parse().unwrap())); - assert_eq!( - entry.trimmed_policy.as_ref().unwrap().network_policies, - policy.network_policies - ); - drop(reg); - - let mut saw_redirect = false; - let deadline = tokio::time::Instant::now() + Duration::from_secs(15); - while tokio::time::Instant::now() < deadline { - match tokio::time::timeout(Duration::from_millis(500), stream.next()).await { - Ok(Some(Ok(ev))) => { - if let Some(watch_sandboxes_event::Payload::PlatformEvent(pe)) = ev.payload - && pe - .event - .as_ref() - .is_some_and(|e| e.reason == "EgressRedirect") - { - saw_redirect = true; - break; - } - } - Ok(_) => break, - Err(_) => continue, - } - } - assert!(saw_redirect, "expected EgressRedirect platform event"); - } - #[tokio::test] async fn negative_out_of_policy_write_is_denied_with_event() { let share_tmp = tempfile::tempdir().unwrap(); @@ -1214,7 +1136,7 @@ mod lifecycle_tests { "-Command".into(), format!("Set-Content -LiteralPath {out_path} -Value hi"), ]; - let backend = MxcComputeBackend::new_mocked(demo_config(&share, cmd)); + 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; @@ -1224,7 +1146,7 @@ mod lifecycle_tests { .await .insert("sb-neg".into(), fs_policy(&[&share])); backend - .create_sandbox(&driver_sandbox("sb-neg")) + .create_sandbox(&driver_sandbox_with_command("sb-neg", &share, cmd)) .await .expect("create accepted"); @@ -1234,19 +1156,18 @@ mod lifecycle_tests { while tokio::time::Instant::now() < deadline { match tokio::time::timeout(Duration::from_millis(500), stream.next()).await { Ok(Some(Ok(ev))) => { - if let Some(watch_sandboxes_event::Payload::PlatformEvent(pe)) = ev.payload { - if pe + if let Some(watch_sandboxes_event::Payload::PlatformEvent(event)) = ev.payload + && event .event .as_ref() - .is_some_and(|e| e.reason == "AgentExecFailed") - { - saw_denial = true; - break; - } + .is_some_and(|event| event.reason == "AgentExecFailed") + { + saw_denial = true; + break; } } Ok(_) => break, - Err(_) => continue, + Err(_) => {} } } assert!( @@ -1266,13 +1187,59 @@ mod lifecycle_tests { assert!(failed.is_some(), "sandbox should report ExecFailed"); } + #[tokio::test] + async fn stop_terminates_and_reaps_a_running_process_container() { + let tmp = tempfile::tempdir().unwrap(); + let share = tmp.path().to_string_lossy().replace('\\', "/"); + let marker = format!("{share}/started.txt"); + let command = vec![ + "powershell".into(), + "-NoProfile".into(), + "-Command".into(), + format!("Set-Content -LiteralPath '{marker}' -Value started; Start-Sleep -Seconds 60"), + ]; + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); + backend + .policy_sink() + .lock() + .await + .insert("sb-stop".into(), fs_policy(&[&share])); + backend + .create_sandbox(&driver_sandbox_with_command("sb-stop", "", command)) + .await + .expect("create accepted"); + wait_for(&backend, "sb-stop", |sandbox| { + ready_condition(sandbox).is_some_and(|condition| condition.reason == "AgentRunning") + }) + .await + .expect("long-running child should start"); + let marker_path = std::path::Path::new(tmp.path()).join("started.txt"); + for _ in 0..100 { + if marker_path.exists() { + break; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + assert!( + marker_path.exists(), + "the long-running child must execute before stop tests cancellation" + ); + + tokio::time::timeout(Duration::from_secs(5), backend.stop_sandbox("sb-stop")) + .await + .expect("stop should not wait for the child sleep") + .expect("stop should terminate and reap the child"); + let stopped = backend.get_sandbox("sb-stop").await.unwrap(); + assert_eq!(ready_condition(&stopped).unwrap().reason, "Stopped"); + } + #[tokio::test] async fn unmappable_network_policy_fails_create_lifecycle() { use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule}; let tmp = tempfile::tempdir().unwrap(); let share = tmp.path().to_string_lossy().replace('\\', "/"); - let cmd = vec!["cmd".into(), "/c".into(), "exit 0".into()]; - let backend = MxcComputeBackend::new_mocked(demo_config(&share, cmd)); + + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); let mut policy = fs_policy(&[&share]); policy.network_policies.insert( @@ -1291,21 +1258,11 @@ mod lifecycle_tests { .lock() .await .insert("sb-net".into(), policy); - backend + let error = backend .create_sandbox(&driver_sandbox("sb-net")) .await - .expect("create accepted (rejection happens in lifecycle)"); - - // Unmappable policy surfaces as a terminal create-time failure, never a - // silent drop. (ValidateSandboxCreate has no policy side channel, so the - // mapper rejection happens at map-time in run_lifecycle.) - let failed = wait_for(&backend, "sb-net", |s| { - ready_condition(s).is_some_and(|c| c.status == "False" && c.reason == "ProvisionFailed") - }) - .await; - assert!( - failed.is_some(), - "network policy on isolation_session must fail the create" - ); + .expect_err("unmappable policy must fail CreateSandbox synchronously"); + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert!(backend.get_sandbox("sb-net").await.is_none()); } } diff --git a/crates/openshell-driver-mxc/src/lib.rs b/crates/openshell-driver-mxc/src/lib.rs index 7347e31dd4..dc251649f8 100644 --- a/crates/openshell-driver-mxc/src/lib.rs +++ b/crates/openshell-driver-mxc/src/lib.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! OpenShell MXC compute driver. +//! `OpenShell` MXC compute driver. //! //! Implements the gateway's `ComputeDriver` gRPC contract backed by Microsoft //! MXC (`wxc-exec`) on Windows. The driver is **in-process**, runs the agent diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs index f64fb621f8..32d7006d90 100644 --- a/crates/openshell-driver-mxc/src/mxc.rs +++ b/crates/openshell-driver-mxc/src/mxc.rs @@ -25,15 +25,13 @@ pub const DEFAULT_CONFIGURATION_ID: &str = "composable"; /// Environment flag selecting the in-process mock `wxc-exec` shim. When set to /// `"1"`, the invoker does NOT spawn the real `wxc-exec.exe`; instead it emits -/// canned provision/start/stop/deprovision results and simulates AppContainer +/// canned provision/start/stop/deprovision results and simulates `AppContainer` /// filesystem-policy enforcement for the exec phase. This is what makes the /// full create → Ready → policy-proof round trip runnable off the demo box. pub const MOCK_ENV_VAR: &str = "OPENSHELL_MXC_MOCK_WXC"; fn mock_enabled() -> bool { - std::env::var(MOCK_ENV_VAR) - .map(|v| v == "1") - .unwrap_or(false) + std::env::var(MOCK_ENV_VAR).is_ok_and(|value| value == "1") } /// Normalize a path/command fragment to lowercase backslash form for the mock's @@ -56,9 +54,10 @@ fn mock_grants() -> &'static Mutex>> { /// /// `isolation_session` honors `readwrite`/`readonly` (grant-only — it has no /// deny primitive). `processContainer` additionally honors `denied_paths` -/// because the AppContainer backend can stamp deny ACEs; it is also genuinely +/// because the `AppContainer` backend can stamp deny ACEs; it is also genuinely /// default-deny, so anything not granted is already inaccessible. #[derive(Debug, Default)] +#[allow(clippy::struct_field_names)] pub struct MxcFilesystem { pub readwrite_paths: Vec, pub readonly_paths: Vec, @@ -72,12 +71,12 @@ pub struct MxcNetwork { pub proxy: Option, } -/// `processContainer`-specific knobs (one-shot AppContainer backend). +/// `processContainer`-specific knobs (one-shot `AppContainer` backend). #[derive(Debug, Default, Clone)] pub struct MxcProcessContainer { - /// Request a Less-Privileged AppContainer (stricter default-deny). + /// Request a Less-Privileged `AppContainer` (stricter default-deny). pub least_privilege: bool, - /// AppContainer capabilities to grant (e.g. `internetClient`). + /// `AppContainer` capabilities to grant (e.g. `internetClient`). pub capabilities: Vec, } @@ -193,7 +192,7 @@ fn mock_configs() -> &'static Mutex> { } #[cfg(test)] -pub(crate) fn mock_recorded_config(id: &str) -> Option { +pub fn mock_recorded_config(id: &str) -> Option { mock_configs().lock().unwrap().get(id).cloned() } @@ -339,13 +338,11 @@ impl WxcExecInvoker { let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); if !output.status.success() { - if let Ok(env) = serde_json::from_str::(&stdout) { - if let MxcEnvelope::Err { error } = env { - return Err(InvokerError::Mxc { - code: error.code, - message: error.message, - }); - } + if let Ok(MxcEnvelope::Err { error }) = serde_json::from_str::(&stdout) { + return Err(InvokerError::Mxc { + code: error.code, + message: error.message, + }); } let code = output.status.code().unwrap_or(-1); return Err(InvokerError::NoEnvelope { @@ -413,13 +410,14 @@ impl WxcExecInvoker { if !output.status.success() { let code = output.status.code().unwrap_or(-1); - if let Ok(env) = serde_json::from_str::(&stdout) { - if let Some(err) = env.error { - return Err(InvokerError::Mxc { - code: err.code, - message: err.message, - }); - } + if let Ok(ProvisionEnvelope { + error: Some(error), .. + }) = serde_json::from_str::(&stdout) + { + return Err(InvokerError::Mxc { + code: error.code, + message: error.message, + }); } return Err(InvokerError::NoEnvelope { exit_code: code, @@ -471,7 +469,7 @@ impl WxcExecInvoker { process: MxcProcess, ) -> Result { if self.mock { - return self.mock_spawn_exec(iso_sandbox_id, &process); + return Self::mock_spawn_exec(iso_sandbox_id, &process); } let config = serde_json::json!({ "version": MXC_SCHEMA_VERSION, @@ -493,8 +491,9 @@ impl WxcExecInvoker { .arg(&b64) .arg("--experimental") .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()); + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .kill_on_drop(true); if self.debug { cmd.arg("--debug"); } @@ -504,12 +503,11 @@ impl WxcExecInvoker { Ok(child) } - /// Mock exec: simulate AppContainer filesystem-policy enforcement. + /// Mock exec: simulate `AppContainer` filesystem-policy enforcement. /// /// The agent's write target is considered **in-policy** iff the command line /// references one of the granted read-write paths recorded at mock provision. fn mock_spawn_exec( - &self, iso_sandbox_id: &str, process: &MxcProcess, ) -> Result { @@ -538,11 +536,15 @@ impl WxcExecInvoker { let mut cmd = Command::new("cmd"); cmd.stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()); + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .kill_on_drop(true); if in_policy { debug!(command = %process.command_line, "mock exec: in-policy, running agent"); - cmd.arg("/c").arg(&process.command_line); + // `command_line` is already encoded with Windows quoting rules. + // Pass it raw so this mock matches wxc-exec/CreateProcess instead + // of asking Rust to quote the entire command as one cmd.exe argv. + cmd.raw_arg(format!("/d /s /c \"{}\"", process.command_line)); } else { debug!(command = %process.command_line, "mock exec: OUT-OF-POLICY, denying"); cmd.arg("/c").arg( @@ -557,8 +559,8 @@ impl WxcExecInvoker { /// /// Unlike the `isolation_session` lifecycle (provision → start → exec → /// stop → deprovision), `processContainer` is a single ephemeral - /// AppContainer: one `wxc-exec` invocation creates the container, runs the - /// one process, and tears down when it exits. The AppContainer is genuinely + /// `AppContainer`: one `wxc-exec` invocation creates the container, runs the + /// one process, and tears down when it exits. The `AppContainer` is genuinely /// default-deny, so a write to any ungranted path is denied by the OS. /// /// **Stdout is raw agent output; the exit code is the agent's own exit code.** @@ -593,8 +595,9 @@ impl WxcExecInvoker { cmd.arg("--config-base64") .arg(&b64) .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()); + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .kill_on_drop(true); if self.debug { cmd.arg("--debug"); } diff --git a/crates/openshell-driver-mxc/src/policy.rs b/crates/openshell-driver-mxc/src/policy.rs index 068873ac8b..1f26d186e5 100644 --- a/crates/openshell-driver-mxc/src/policy.rs +++ b/crates/openshell-driver-mxc/src/policy.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! PolicyMapper seam: `SandboxPolicy` → MXC `ContainerConfig` fragment. +//! `PolicyMapper` seam: `SandboxPolicy` → MXC `ContainerConfig` fragment. //! //! This file does **not** write the actual policy mapping rules — that logic is //! **embedded** as the [`crate::policy_map`] module (the source of truth; it was @@ -13,11 +13,9 @@ //! proto (no YAML bridge), extracts the MXC filesystem shares, normalizes //! their paths to Windows form, and rejects the create on any `error`-severity //! loss. -//! - [`StubPolicyMapper`] — a compile-only fallback that grants only the demo -//! `share_dir`. Kept so the crate builds/tests without exercising the embed. //! //! **Rule: never silently drop policy.** Unmappable rules surface as -//! `MapError::Unsupported` and are rejected in `ValidateSandboxCreate`. +//! `MapError::Unsupported` and are rejected by `CreateSandbox` before lifecycle side effects. use std::net::SocketAddr; @@ -48,9 +46,6 @@ pub struct MapCtx { /// Sandbox ID (gateway-assigned). Used as the MXC `containerId` and to /// correlate diagnostics. pub sandbox_id: String, - /// Host share directory for the demo positive proof. Always granted - /// read-write so `hello.txt` is visible on the host. - pub share_dir: Option, /// Pattern-C governed-egress redirect address. When set, the embedded /// mapper uses `split_policy`; otherwise it uses the coarse MXC map. pub egress: Option, @@ -80,7 +75,7 @@ fn format_loss(items: &[LossItem]) -> String { .join("; ") } -/// Translates an OpenShell `SandboxPolicy` into an MXC `ContainerConfig` +/// Translates an `OpenShell` `SandboxPolicy` into an MXC `ContainerConfig` /// fragment, returning a loss report of anything unrepresentable. pub trait PolicyMapper: Send + Sync { /// `policy` is `None` only when the gateway failed to stage one (the MXC @@ -171,9 +166,9 @@ impl PolicyMapper for EmbeddedPolicyMapper { return Err(MapError::Unsupported(errors)); } - // The embedded mapper copies paths verbatim; normalize them (and the - // demo share dir) to Windows backslash form here, in one place. - let mut readwrite: Vec = extract_paths(&config, "readwritePaths") + // The embedded mapper copies paths verbatim; normalize them to Windows + // backslash form here, in one place. + let readwrite: Vec = extract_paths(&config, "readwritePaths") .iter() .map(|p| normalize_path(p)) .collect(); @@ -182,16 +177,6 @@ impl PolicyMapper for EmbeddedPolicyMapper { .map(|p| normalize_path(p)) .collect(); - // Always grant the demo host-visible share read-write so the positive - // proof artifact (`hello.txt`) appears on the host. For the demo this - // equals the policy's read_write path, so it does not broaden access. - if let Some(dir) = &ctx.share_dir { - let norm = normalize_path(dir); - if !readwrite.contains(&norm) { - readwrite.push(norm); - } - } - Ok(MappedConfig { readwrite_paths: readwrite, readonly_paths: readonly, @@ -201,38 +186,14 @@ impl PolicyMapper for EmbeddedPolicyMapper { } } -// ── Stub implementation (compile-only fallback) ───────────────────────────── - -/// Compile-only stub that applies only the demo's filesystem grant. -/// -/// Ignores the policy and maps `ctx.share_dir` as a read-write path. Kept so the -/// crate compiles/tests without exercising the embedded mapper. **Not sufficient -/// for a meaningful policy demo** — use [`EmbeddedPolicyMapper`]. -/// -/// Retained as a documented scaffolding fallback (see SKILL Step 7); the default -/// backend uses [`EmbeddedPolicyMapper`], so this is unused outside tests. -#[allow(dead_code)] -pub struct StubPolicyMapper; - -impl PolicyMapper for StubPolicyMapper { - fn map(&self, _policy: Option<&SandboxPolicy>, ctx: &MapCtx) -> Result { - let mut config = MappedConfig::default(); - if let Some(ref dir) = ctx.share_dir { - config.readwrite_paths.push(normalize_path(dir)); - } - Ok(config) - } -} - #[cfg(test)] mod tests { use super::*; use openshell_core::proto::FilesystemPolicy; - fn demo_ctx(share_dir: Option<&str>) -> MapCtx { + fn demo_ctx() -> MapCtx { MapCtx { sandbox_id: "sb-test".into(), - share_dir: share_dir.map(str::to_string), egress: None, } } @@ -241,27 +202,18 @@ mod tests { SandboxPolicy { filesystem: Some(FilesystemPolicy { include_workdir: false, - read_only: ro.iter().map(|s| s.to_string()).collect(), - read_write: rw.iter().map(|s| s.to_string()).collect(), + read_only: ro.iter().map(ToString::to_string).collect(), + read_write: rw.iter().map(ToString::to_string).collect(), }), ..Default::default() } } - #[test] - fn stub_maps_share_dir_as_readwrite() { - let mapper = StubPolicyMapper; - let ctx = demo_ctx(Some("C:\\work\\demo")); - let config = mapper.map(None, &ctx).unwrap(); - assert_eq!(config.readwrite_paths, vec!["C:\\work\\demo"]); - assert!(config.readonly_paths.is_empty()); - } - #[test] fn embedded_maps_policy_read_write_to_share() { let mapper = EmbeddedPolicyMapper; let policy = fs_policy(&["C:/work/openshell-mxc-demo"], &["C:/tools"]); - let ctx = demo_ctx(Some("C:/work/openshell-mxc-demo")); + let ctx = demo_ctx(); let config = mapper.map(Some(&policy), &ctx).unwrap(); // Forward slashes normalized to Windows backslashes by the bridge. assert!( @@ -275,7 +227,7 @@ mod tests { #[test] fn embedded_rejects_missing_policy() { let mapper = EmbeddedPolicyMapper; - let ctx = demo_ctx(Some("C:/work/demo")); + let ctx = demo_ctx(); let err = mapper.map(None, &ctx).unwrap_err(); assert!(matches!(err, MapError::Internal(_))); } @@ -296,7 +248,7 @@ mod tests { binaries: Vec::new(), }, ); - let ctx = demo_ctx(Some("C:/work/demo")); + let ctx = demo_ctx(); let err = mapper.map(Some(&policy), &ctx).unwrap_err(); assert!(matches!(err, MapError::Unsupported(_))); } @@ -326,7 +278,7 @@ mod tests { let proxy_addr = "127.0.0.1:18080".parse().unwrap(); let ctx = MapCtx { sandbox_id: "sb-egress".into(), - share_dir: Some("C:/work/demo".into()), + egress: Some(proxy_addr), }; diff --git a/crates/openshell-driver-mxc/src/policy_map/map.rs b/crates/openshell-driver-mxc/src/policy_map/map.rs index 33e905222a..7d9025e5a6 100644 --- a/crates/openshell-driver-mxc/src/policy_map/map.rs +++ b/crates/openshell-driver-mxc/src/policy_map/map.rs @@ -177,7 +177,6 @@ fn build_split_mxc_config( // MXC 0.6.0-alpha schema accepts ONLY {"proxy": {"localhost": }}. // {"host": ..., "port": ...} and every other shape is rejected — verified // empirically against the real wxc-exec 0.6.0-alpha binary via --dry-run. - // See also docs/reference/mxc-compute-driver-design.mdx §network.proxy. if proxy_supported && proxy_addr.ip() != std::net::IpAddr::from([127, 0, 0, 1]) { add_loss( items, @@ -185,9 +184,8 @@ fn build_split_mxc_config( "error", &format!( "MXC schema 0.6.0-alpha can only express a localhost port \ - ({{\"localhost\": N}}); non-127.0.0.1 redirect address {} \ - is not representable.", - proxy_addr + ({{\"localhost\": N}}); non-127.0.0.1 redirect address \ + {proxy_addr} is not representable." ), "per-sandbox egress attribution", "The redirect cannot be emitted; use a 127.0.0.1:PORT address.", diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs index fc8e953fc0..8acea1e696 100644 --- a/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs +++ b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs @@ -14,6 +14,12 @@ //! `openshell-policy` gains a serialized field the mapper does not account for. #![cfg(target_os = "windows")] +#![allow( + clippy::doc_link_with_quotes, + clippy::doc_markdown, + clippy::needless_collect, + clippy::uninlined_format_args +)] use openshell_core::proto::{ FilesystemPolicy, GraphqlOperation, L7Allow, L7DenyRule, L7Rule, LandlockPolicy, @@ -927,7 +933,6 @@ fn b_seam_returns_unsupported_on_error_field() { ); let ctx = MapCtx { sandbox_id: "sb-test".into(), - share_dir: None, egress: None, // coarse path → isolation_session → network policy errors }; let err = mapper.map(Some(&policy), &ctx).unwrap_err(); diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs index e5ca3bb382..dd16836e4b 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -15,14 +15,14 @@ //! Two families: //! //! **(a) Dry-run contract tests** — exercise `--dry-run` only; pass/fail on -//! schema acceptance. These pass on this box even though no enforcement -//! backend is live (dry-run validates the JSON schema without spinning up the -//! AppContainer or isolation session). +//! schema acceptance. Some `wxc-exec` builds select the DACL fallback during +//! dry-run and validate filesystem grants, so these tests use owned temporary +//! directories with concrete Windows paths. //! //! **(b) Enforcement tests** — probe-gated; print a human-readable SKIP reason //! and return early when the backend is not live. The probe distinguishes -//! "binary absent", "backend_error / velocity keys not enabled", and -//! "backend_unavailable". +//! "binary absent", "`backend_error` / velocity keys not enabled", and +//! "`backend_unavailable`". //! //! IMPORTANT: `OPENSHELL_MXC_MOCK_WXC` must NOT be set when running this file. //! The probe-gated enforcement tests assert that it is absent so a stale env @@ -94,17 +94,19 @@ fn dryrun_accepts_minimal_processcontainer_config() { return; }; + let tmpdir = tempfile::tempdir().expect("tempdir"); + let tmpdir_str = tmpdir.path().to_string_lossy().into_owned(); let config = serde_json::json!({ "version": "0.6.0-alpha", "containerId": "test-minimal", "containment": "processcontainer", "process": { "commandLine": "cmd /c exit 0", - "cwd": "%TEMP%", + "cwd": tmpdir_str, "timeout": 0, }, "filesystem": { - "readwritePaths": ["%TEMP%"], + "readwritePaths": [tmpdir_str], }, }); @@ -124,17 +126,19 @@ fn dryrun_accepts_network_block_without_proxy() { return; }; + let tmpdir = tempfile::tempdir().expect("tempdir"); + let tmpdir_str = tmpdir.path().to_string_lossy().into_owned(); let config = serde_json::json!({ "version": "0.6.0-alpha", "containerId": "test-net-block", "containment": "processcontainer", "process": { "commandLine": "cmd /c exit 0", - "cwd": "%TEMP%", + "cwd": tmpdir_str, "timeout": 0, }, "filesystem": { - "readwritePaths": ["%TEMP%"], + "readwritePaths": [tmpdir_str], }, "network": { "defaultPolicy": "block", @@ -161,17 +165,19 @@ fn dryrun_accepts_localhost_proxy_shape() { return; }; + let tmpdir = tempfile::tempdir().expect("tempdir"); + let tmpdir_str = tmpdir.path().to_string_lossy().into_owned(); let config = serde_json::json!({ "version": "0.6.0-alpha", "containerId": "test-proxy-localhost", "containment": "processcontainer", "process": { "commandLine": "cmd /c exit 0", - "cwd": "%TEMP%", + "cwd": tmpdir_str, "timeout": 0, }, "filesystem": { - "readwritePaths": ["%TEMP%"], + "readwritePaths": [tmpdir_str], }, "network": { "defaultPolicy": "block", @@ -256,7 +262,7 @@ fn dryrun_rejects_unknown_containment() { } /// The most important dry-run test: parse the quickstart example policy with -/// `openshell_policy`, run `split_policy` (proxy_redirect 127.0.0.1:18080, +/// `openshell_policy`, run `split_policy` (`proxy_redirect` 127.0.0.1:18080, /// containment "processcontainer"), take the resulting `mxc_config`, inject a /// real process block with a valid cwd, and verify that `--dry-run` exits 0. /// @@ -313,10 +319,22 @@ fn dryrun_accepts_split_policy_output() { // Take the mapper's MXC config and inject the required process block. // The split config does not include a process block (that comes from the // gateway TOML at runtime); wxc-exec --dry-run requires one. + // + // The quickstart policy uses sandbox-internal Unix paths. Replace only the + // environment-dependent filesystem paths with an owned Windows directory: + // this test verifies the mapper's MXC JSON shape, while mapper unit tests + // cover the exact filesystem translation. + let tmpdir = tempfile::tempdir().expect("tempdir"); + let tmpdir_str = tmpdir.path().to_string_lossy().into_owned(); let mut mxc_config = result.mxc_config.clone(); + mxc_config["filesystem"] = serde_json::json!({ + "readwritePaths": [tmpdir_str], + "readonlyPaths": [], + "deniedPaths": [], + }); mxc_config["process"] = serde_json::json!({ "commandLine": "cmd /c exit 0", - "cwd": "%TEMP%", + "cwd": tmpdir_str, "timeout": 0, }); // containerId is also required for processcontainer. @@ -340,33 +358,32 @@ fn dryrun_accepts_split_policy_output() { /// Probe the processcontainer backend. /// -/// Runs a trivial one-shot (`cmd /c exit 0`, `%TEMP%` grant). Returns -/// `Ok(())` when the backend is live, or `Err(reason)` when it is not (the +/// Runs a trivial one-shot (`cmd /c exit 0`, owned temporary-directory grant). +/// Returns `Ok(())` when the backend is live, or `Err(reason)` when it is not (the /// caller prints SKIP + reason and returns from the test). fn probe_processcontainer(wxc: &PathBuf) -> Result<(), String> { // Abort early if the mock env var is set — a stale OPENSHELL_MXC_MOCK_WXC // would silently turn this "real" run back into a mock run. - if std::env::var("OPENSHELL_MXC_MOCK_WXC") - .map(|v| v == "1") - .unwrap_or(false) - { + if std::env::var("OPENSHELL_MXC_MOCK_WXC").is_ok_and(|value| value == "1") { return Err( "OPENSHELL_MXC_MOCK_WXC=1 is set — unset it before running real enforcement tests" .to_string(), ); } + let tmpdir = tempfile::tempdir().map_err(|error| format!("tempdir failed: {error}"))?; + let tmpdir_str = tmpdir.path().to_string_lossy().into_owned(); let config = serde_json::json!({ "version": "0.6.0-alpha", "containerId": "probe-pc", "containment": "processcontainer", "process": { "commandLine": "cmd /c exit 0", - "cwd": "%TEMP%", + "cwd": tmpdir_str, "timeout": 10, }, "filesystem": { - "readwritePaths": ["%TEMP%"], + "readwritePaths": [tmpdir_str], }, }); @@ -389,16 +406,17 @@ fn probe_processcontainer(wxc: &PathBuf) -> Result<(), String> { || combined.contains("not enabled") { // Extract the message if possible for a more useful skip reason. - let reason = if let Ok(v) = + let reason = serde_json::from_str::(&String::from_utf8_lossy(&out.stdout)) - { - v["error"]["message"] - .as_str() - .unwrap_or("backend_error (E_NOTIMPL)") - .to_string() - } else { - "backend_error (velocity keys not enabled)".to_string() - }; + .map_or_else( + |_| "backend_error (velocity keys not enabled)".to_string(), + |value| { + value["error"]["message"] + .as_str() + .unwrap_or("backend_error (E_NOTIMPL)") + .to_string() + }, + ); return Err(reason); } @@ -414,15 +432,12 @@ fn probe_processcontainer(wxc: &PathBuf) -> Result<(), String> { Ok(()) } -/// Probe the isolation_session backend. +/// Probe the `isolation_session` backend. /// /// Attempts a `provision` phase. Returns `Ok(sandbox_id)` when live, or /// `Err(reason)` when the backend is unavailable (caller prints SKIP). fn probe_isolation_session(wxc: &PathBuf) -> Result { - if std::env::var("OPENSHELL_MXC_MOCK_WXC") - .map(|v| v == "1") - .unwrap_or(false) - { + if std::env::var("OPENSHELL_MXC_MOCK_WXC").is_ok_and(|value| value == "1") { return Err( "OPENSHELL_MXC_MOCK_WXC=1 is set — unset it before running real enforcement tests" .to_string(), @@ -605,7 +620,7 @@ fn pc_oneshot_in_policy_write_succeeds() { } /// Write to a path OUTSIDE the granted dir; assert exit non-zero and file absent. -/// This is the genuine OS default-deny proof — the AppContainer blocks the write +/// This is the genuine OS default-deny proof — the `AppContainer` blocks the write /// without requiring any host ACL lockdown. The mock can only fake this. #[test] #[ignore = "requires real wxc-exec"] @@ -670,7 +685,7 @@ fn pc_oneshot_out_of_policy_write_denied() { // ── Isolation session enforcement tests ────────────────────────────────────── -/// Full isolation_session round trip: provision → start → exec → stop → +/// Full `isolation_session` round trip: provision → start → exec → stop → /// deprovision. `deprovision` runs in a drop-guard even on panic so the /// single-session backend is never left orphaned. #[test] diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index f1639b822d..2a7e86cdbd 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -782,7 +782,12 @@ fn effective_single_driver(args: &RunArgs) -> Option { fn is_singleplayer_driver(args: &RunArgs) -> bool { matches!( effective_single_driver(args), - Some(ComputeDriverKind::Docker | ComputeDriverKind::Podman | ComputeDriverKind::Vm) + Some( + ComputeDriverKind::Docker + | ComputeDriverKind::Podman + | ComputeDriverKind::Vm + | ComputeDriverKind::Mxc + ) ) } @@ -1565,7 +1570,7 @@ ssh_session_ttl_secs = 1234 #[test] fn singleplayer_driver_matches_only_one_local_driver() { - for driver in ["docker", "podman", "vm"] { + for driver in ["docker", "podman", "vm", "mxc"] { let (args, _) = parse_with_args(&[ "openshell-gateway", "--db-url", diff --git a/crates/openshell-server/src/compute/driver_config.rs b/crates/openshell-server/src/compute/driver_config.rs index 4e379d4a7b..eded21c5f8 100644 --- a/crates/openshell-server/src/compute/driver_config.rs +++ b/crates/openshell-server/src/compute/driver_config.rs @@ -12,9 +12,9 @@ pub mod builtin; use crate::config_file; use crate::defaults::LocalTlsPaths; -use openshell_core::{Error, Result}; #[cfg(target_os = "windows")] use openshell_core::ComputeDriverKind; +use openshell_core::{Error, Result}; #[cfg(target_os = "windows")] use openshell_driver_mxc::MxcComputeConfig; use serde::Deserialize; diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 037c17ab10..cded0e1527 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -31,6 +31,8 @@ use futures::{Stream, StreamExt}; #[cfg(unix)] use hyper_util::rt::TokioIo; use openshell_core::ComputeDriverKind; +#[cfg(target_os = "windows")] +use openshell_core::proto::SandboxPolicy; use openshell_core::proto::compute::v1::{ CreateSandboxRequest, DeleteSandboxRequest, DriverCondition, DriverPlatformEvent, DriverResourceRequirements, DriverSandbox, DriverSandboxSpec, DriverSandboxStatus, @@ -54,12 +56,10 @@ use openshell_driver_docker::DockerComputeDriver; use openshell_driver_kubernetes::{ ComputeDriverService as KubernetesDriverService, KubernetesComputeDriver, }; -#[cfg(not(target_os = "windows"))] -use openshell_driver_podman::{ComputeDriverService as PodmanDriverService, PodmanComputeDriver}; #[cfg(target_os = "windows")] use openshell_driver_mxc::{ComputeDriverService as MxcDriverService, MxcComputeConfig}; -#[cfg(target_os = "windows")] -use openshell_core::proto::SandboxPolicy; +#[cfg(not(target_os = "windows"))] +use openshell_driver_podman::{ComputeDriverService as PodmanDriverService, PodmanComputeDriver}; use prost::Message; use std::collections::HashMap; use std::fmt; diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 9f8a43a992..5d92e9c7a0 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -2290,6 +2290,20 @@ pub(super) async fn handle_get_sandbox_provider_environment( // Update config handler (policy + settings mutations) // --------------------------------------------------------------------------- +fn validate_live_policy_update_support( + driver_kind: Option, + has_policy: bool, + has_merge_ops: bool, +) -> Result<(), Status> { + if (has_policy || has_merge_ops) && driver_kind == Some(openshell_core::ComputeDriverKind::Mxc) + { + return Err(Status::failed_precondition( + "live policy updates are not supported for MXC sandboxes; recreate the sandbox so the new policy is mapped before launch", + )); + } + Ok(()) +} + pub(super) async fn handle_update_config( state: &Arc, request: Request, @@ -2364,6 +2378,7 @@ async fn handle_update_config_inner( "one of policy, setting_key, or merge_operations must be provided", )); } + validate_live_policy_update_support(state.compute.driver_kind(), has_policy, has_merge_ops)?; if req.global { if !req.annotations.is_empty() { return Err(Status::invalid_argument( @@ -5437,6 +5452,27 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use tonic::Code; + #[test] + fn mxc_rejects_sandbox_policy_replacement_and_merge_updates() { + for (has_policy, has_merge_ops) in [(true, false), (false, true)] { + let error = validate_live_policy_update_support( + Some(openshell_core::ComputeDriverKind::Mxc), + has_policy, + has_merge_ops, + ) + .expect_err("MXC must reject policy mutations after launch"); + assert_eq!(error.code(), Code::FailedPrecondition); + } + + let error = validate_live_policy_update_support( + Some(openshell_core::ComputeDriverKind::Mxc), + true, + false, + ) + .expect_err("global policy replacement also changes desired state for live MXC sandboxes"); + assert_eq!(error.code(), Code::FailedPrecondition); + } + /// Wrap a request with a user `Principal` so handler scope guards treat /// the test caller as a CLI user. Most handler tests exercise /// user-facing behavior and should not trip sandbox equality checks. diff --git a/docs/about/how-it-works.mdx b/docs/about/how-it-works.mdx index 5223072a28..bc3bf1ddfb 100644 --- a/docs/about/how-it-works.mdx +++ b/docs/about/how-it-works.mdx @@ -68,6 +68,10 @@ flowchart TB ROUTER -->|"managed inference"| MODEL["Inference backends"] ``` + +The Windows MXC compute driver is an exec-in-driver exception to this supervisor data path. It launches and monitors a one-shot workload without a supervisor session, interactive connect, live policy delivery, or governed egress. + + ## Deployment Models OpenShell can run on a single local machine or in a remote Kubernetes cluster. @@ -98,7 +102,7 @@ device plugins without changing the gateway and sandbox contract. The gateway and sandbox split control-plane authority from runtime enforcement. The gateway owns durable platform state: sandboxes, policy revisions, runtime settings, provider records, inference configuration, session records, and authorization decisions. A sandbox owns the local execution boundary: process identity, filesystem access, network egress, credential injection, local logs, and the agent child process. -The relationship is supervisor initiated. Each sandbox supervisor connects outbound to a known gateway endpoint, authenticates as a sandbox workload, and keeps a live session open for control traffic and relays. This avoids requiring every compute driver to solve gateway-to-sandbox reachability through pod IPs, bridge networks, port mappings, NAT traversal, or custom tunnels. +For supervisor-backed drivers, the relationship is supervisor initiated. Each sandbox supervisor connects outbound to a known gateway endpoint, authenticates as a sandbox workload, and keeps a live session open for control traffic and relays. This avoids requiring those compute drivers to solve gateway-to-sandbox reachability through pod IPs, bridge networks, port mappings, NAT traversal, or custom tunnels. The Windows MXC driver has no supervisor session and supports only its documented create-time workload lifecycle. The gateway delivers desired state. The supervisor applies it locally, keeps last-known-good config when refresh fails, and leaves static isolation controls in place until the sandbox is recreated. Live operations such as config refresh, policy updates, credential delivery, log push, connect, exec, file sync, and relay setup use the same authenticated gateway-supervisor relationship. diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 2cd10b8a0b..de2ac2a016 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -469,6 +469,32 @@ proxy_uid = 1337 process_binary_aware_network_policy = true ``` +### Windows MXC + +The native Windows gateway links the MXC compute driver in-process. Gateway configuration contains host runtime settings only; workload command, working directory, and environment are sandbox-scoped. + +```toml +[openshell] +version = 1 + +[openshell.gateway] +bind_address = "127.0.0.1:17670" +log_level = "info" +compute_drivers = ["mxc"] + +[openshell.drivers.mxc] +wxc_exec_path = "C:\\mxc\\wxc-exec.exe" +# process_container is the default and enforces default-deny filesystem access. +# isolation_session is an explicit grant-only compatibility mode. +backend = "process_container" +pc_least_privilege = false +pc_capabilities = [] +default_configuration_id = "composable" +debug = false +``` + +Unknown MXC fields are rejected. Network policies and live policy mutations fail closed until the driver has a bound enforcement path. See [Sandbox Compute Drivers](sandbox-compute-drivers.mdx#windows-mxc-driver) for per-sandbox workload configuration. + ### Docker Sandboxes run as containers on a local bridge network. The supervisor binary is bind-mounted from the host (no in-cluster image pull required); guest mTLS material is supplied as host paths. diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index ea4c1a37b0..fd65c3a4c4 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -3,14 +3,14 @@ # SPDX-License-Identifier: Apache-2.0 title: "Sandbox Compute Drivers" sidebar-title: "Compute Drivers" -description: "Reference for Docker, Podman, MicroVM, and Kubernetes sandbox compute drivers." -keywords: "Generative AI, Cybersecurity, AI Agents, Sandboxing, Docker, Podman, MicroVM, Kubernetes, Reference" +description: "Reference for Docker, Podman, MicroVM, Kubernetes, and Windows MXC sandbox compute drivers." +keywords: "Generative AI, Cybersecurity, AI Agents, Sandboxing, Docker, Podman, MicroVM, Kubernetes, Windows, MXC, Reference" position: 4 --- -The gateway's configured compute driver determines how OpenShell creates each sandbox. The CLI workflow stays the same across drivers: you create, connect to, inspect, and delete sandboxes through the gateway API. +The gateway's configured compute driver determines how OpenShell creates each sandbox. Docker, Podman, MicroVM, and Kubernetes support create, connect, inspect, and delete through the gateway. Windows MXC supports create, inspect, stop, and delete, but its one-shot exec-in-driver model does not expose an interactive connection. -Every compute driver runs the OpenShell supervisor inside the sandbox workload. The supervisor launches the agent process, applies policy, routes egress through the proxy, injects configured credentials, and maintains the gateway session. +Docker, Podman, Kubernetes, and MicroVM drivers run the OpenShell supervisor inside the sandbox workload. The Windows MXC driver is a one-shot, exec-in-driver integration without a supervisor or interactive gateway session. ## Configure a Compute Driver @@ -21,7 +21,7 @@ Configure the compute driver on the gateway. Current releases accept one driver compute_drivers = ["docker"] ``` -Reserved built-in values are `docker`, `podman`, `kubernetes`, and `vm`. +Reserved built-in values are `docker`, `podman`, `kubernetes`, `vm`, and `mxc`. Non-reserved names select an extension driver and require a `socket_path` in `[openshell.drivers.]`. @@ -31,7 +31,7 @@ Common gateway options: | Gateway TOML option | Description | |---|---| -| `compute_drivers = [""]` | Select the compute driver. Built-in values are `docker`, `podman`, `kubernetes`, and `vm`; custom names require `[openshell.drivers.].socket_path`. | +| `compute_drivers = [""]` | Select the compute driver. Built-in values are `docker`, `podman`, `kubernetes`, `vm`, and `mxc`; custom names require `[openshell.drivers.].socket_path`. | Set driver-specific values such as sandbox images, callback endpoints, network names, TLS material, and VM sizing in the gateway TOML file. See the [Gateway Configuration File](./gateway-config) reference for the full `[openshell.drivers.]` schema. @@ -119,6 +119,33 @@ and register `https://localhost:17670` as the CLI endpoint. The hostname matches the generated certificate and avoids the TLS transport error produced by a raw IPv6-literal endpoint. Do not broaden the primary listener to `0.0.0.0`. +## Windows MXC Driver + +The MXC driver runs in-process in a native Windows gateway and invokes `wxc-exec.exe`. Configure the gateway runtime separately from each sandbox workload: + +```toml +[openshell.gateway] +compute_drivers = ["mxc"] + +[openshell.drivers.mxc] +wxc_exec_path = "C:\\mxc\\wxc-exec.exe" +backend = "process_container" +``` + +`process_container` is the default because Windows AppContainer enforces default-deny filesystem access. `isolation_session` is an explicit compatibility mode that grants configured paths but cannot deny access to omitted paths. + +Supply the workload command and optional working directory in per-sandbox driver config: + +```powershell +$config = '{"mxc":{"command":["cmd","/c","echo hello > C:\\\\work\\\\demo\\\\hello.txt"],"cwd":"C:\\\\work\\\\demo"}}' +openshell sandbox create --name mxc-demo --policy demo.yaml ` + --driver-config-json $config --env MODE=demo --no-tty +``` + +The command array preserves Windows argument boundaries. The sandbox policy is the only source of filesystem grants. MXC rejects network policies and live policy replacement or merge updates; delete and recreate the sandbox to change policy. + +MXC does not provide the supervisor, SSH, interactive exec, port forwarding, provider credential refresh, or governed egress. Stop and delete terminate and reap the workload process before reporting success. + ## Docker Driver [Docker](https://www.docker.com/get-started/)-backed sandboxes run as containers on the gateway host. Use Docker for local development, single-machine gateways, and hosts that already use Docker Desktop or Docker Engine. diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index f6596640a8..8b5ec87797 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -466,9 +466,9 @@ temporarily while its supervisor reconnects. Wait for the phase to return to ## Sandbox Compute Drivers -The gateway's configured compute driver determines how OpenShell creates each sandbox. The CLI workflow stays the same across drivers: you create, connect to, inspect, and delete sandboxes through the gateway API. +The gateway's configured compute driver determines how OpenShell creates each sandbox. Docker, Podman, MicroVM, and Kubernetes support create, connect, inspect, and delete through the gateway. Windows MXC supports create, inspect, stop, and delete, but its one-shot exec-in-driver model does not expose an interactive connection. -For Docker, Podman, MicroVM, and Kubernetes behavior, refer to [Sandbox Compute Drivers](/reference/sandbox-compute-drivers). +For Docker, Podman, MicroVM, Kubernetes, and Windows MXC behavior, refer to [Sandbox Compute Drivers](/reference/sandbox-compute-drivers). ## Next Steps