From 1ebc297b8b5fe555bd681c09f6d4644fbb0fa818 Mon Sep 17 00:00:00 2001 From: Jamie King Date: Wed, 8 Jul 2026 20:09:37 -0600 Subject: [PATCH 01/31] feat(mxc): ETW->OCSF audit consumer + Windows OCSF JSONL parity (cp6 P1) Add a Windows MXC ETW->OCSF audit trail in openshell-driver-mxc: a real-time Sandboxing-provider ETW consumer that decodes events (TDH), attributes each to an OpenShell sandbox_id, and maps them to OCSF (lifecycle 6002, config 5019, process 1007, finding 2004). cp6 Phase 1 - durable OCSF JSONL audit-file parity with Linux: - openshell-ocsf: add emit_ocsf_event_routed (populates the event-bridge thread-local AND stamps sandbox_id+message in one dispatch) plus public set/clear_current_event; OS-aware device (Device::windows/for_current_os) so device.os.name reflects the host instead of a hardcoded Linux stub. - etw_consumer: emit via the routed emit (previously fired a bare info! that never populated the bridge, so the structured event was dropped). - openshell-server: install OcsfJsonlLayer over a synchronous daily-rotated appender (durable under force-kill), gated by OPENSHELL_OCSF_JSON, path via %PROGRAMDATA%\OpenShell\logs (override OPENSHELL_OCSF_LOG_DIR). - device.hostname now resolves to the real gateway machine name. Box-proven on 7F203-MXC-001: JSONL lines == shorthand OCSF rows, all valid OCSF JSON, per-sandbox attribution intact, disabled state writes nothing. Signed-off-by: Akber Raza --- Cargo.lock | 3 + Cargo.toml | 3 + crates/openshell-driver-mxc/Cargo.toml | 9 + crates/openshell-driver-mxc/src/driver.rs | 54 + .../openshell-driver-mxc/src/etw_consumer.rs | 1235 +++++++++++++++++ crates/openshell-driver-mxc/src/lib.rs | 5 + crates/openshell-ocsf/src/builders/mod.rs | 5 +- crates/openshell-ocsf/src/lib.rs | 3 +- crates/openshell-ocsf/src/objects/device.rs | 28 + .../src/tracing_layers/event_bridge.rs | 107 +- .../openshell-ocsf/src/tracing_layers/mod.rs | 5 +- crates/openshell-server/Cargo.toml | 1 + crates/openshell-server/src/tracing_setup.rs | 93 ++ 13 files changed, 1544 insertions(+), 7 deletions(-) create mode 100644 crates/openshell-driver-mxc/src/etw_consumer.rs diff --git a/Cargo.lock b/Cargo.lock index 011730760d..ba10b9f01a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4029,6 +4029,7 @@ dependencies = [ "base64 0.22.1", "futures", "openshell-core", + "openshell-ocsf", "openshell-policy", "serde", "serde_json", @@ -4040,6 +4041,7 @@ dependencies = [ "tonic", "tracing", "uuid", + "windows", ] [[package]] @@ -4415,6 +4417,7 @@ dependencies = [ "tower 0.5.3", "tower-http 0.6.8", "tracing", + "tracing-appender", "tracing-opentelemetry", "tracing-subscriber", "url", diff --git a/Cargo.toml b/Cargo.toml index 57b77d0716..b2593b2484 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,9 @@ terminal-colorsaurus = "1.0" # Error handling miette = { version = "7", features = ["fancy"] } thiserror = "2" + +# Windows platform APIs (ETW/TDH audit consumer in openshell-driver-mxc; Windows-only) +windows = { version = "0.62", features = ["Win32_Foundation", "Win32_System_Diagnostics_Etw", "Win32_System_Time"] } anyhow = "1" # Logging/Tracing diff --git a/crates/openshell-driver-mxc/Cargo.toml b/crates/openshell-driver-mxc/Cargo.toml index 2f82e50e3b..b05c7e82e1 100644 --- a/crates/openshell-driver-mxc/Cargo.toml +++ b/crates/openshell-driver-mxc/Cargo.toml @@ -15,6 +15,10 @@ name = "openshell_driver_mxc" [dependencies] openshell-core = { path = "../openshell-core" } +# OCSF builders + emit target used by the Windows ETW audit consumer. OS-agnostic +# crate (no windows deps), so safe to depend on from all targets; only the +# windows-gated `etw_consumer` module actually uses it. +openshell-ocsf = { path = "../openshell-ocsf" } tokio = { workspace = true } tonic = { workspace = true } futures = { workspace = true } @@ -26,6 +30,11 @@ tracing = { workspace = true } thiserror = { workspace = true } uuid = { workspace = true } +# ETW/TDH real-time consumer (Plane A audit). Windows-only so the Linux/WSL +# build stays an empty stub. +[target.'cfg(target_os = "windows")'.dependencies] +windows = { workspace = true } + [dev-dependencies] tokio = { workspace = true } # tempfile is not a workspace dependency; 3.27 is already resolved in Cargo.lock. diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index d8e981a46d..97234ba792 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -68,6 +68,10 @@ pub struct MxcComputeConfig { /// Enable `--debug` flag on `wxc-exec` invocations. pub debug: bool, + /// Enable the in-process ETW → OCSF audit consumer (Plane A). Consumes the OS + /// Sandboxing provider MXC drives and emits OCSF into the gateway trail. + /// Requires the gateway account to be in "Performance Log Users" (or admin). + pub etw_audit: bool, } impl Default for MxcComputeConfig { @@ -80,6 +84,7 @@ impl Default for MxcComputeConfig { default_configuration_id: crate::mxc::DEFAULT_CONFIGURATION_ID.into(), debug: false, + etw_audit: false, } } } @@ -184,6 +189,17 @@ pub struct MxcComputeBackend { /// immediately before dispatching to this backend's `create_sandbox`, which /// removes/consumes it. pending_policies: Arc>>, + /// In-process ETW → OCSF audit consumer (Plane A). `Some` only when + /// `config.etw_audit` is set and the session started; kept alive here so it + /// stops when the backend is dropped (held purely for its `Drop`, hence + /// never read directly). + #[allow(dead_code)] + etw_session: Option, + /// Shared MXC-ETW → `sandbox_id` attribution index. Seeded by the driver + /// (`pid → sandbox_id`) as it launches sandboxes and read by the ETW + /// consumer thread to map/emit OCSF. `Arc` even when audit is off so the + /// launch path is branch-free. + attribution: Arc>, } impl std::fmt::Debug for MxcComputeBackend { @@ -271,6 +287,26 @@ impl MxcComputeBackend { pub fn new(config: MxcComputeConfig) -> Self { let invoker = WxcExecInvoker::new(&config.wxc_exec_path, config.debug); let (watch_tx, _) = broadcast::channel(256); + + // Start the Plane-A ETW → OCSF consumer if enabled. The consumer thread + // attributes each event to a `sandbox_id` via `attribution` (seeded by + // the launch path) and emits OCSF for the mapped classes. + // Failure is non-fatal — the driver still runs, just without ETW audit. + let attribution = Arc::new(std::sync::Mutex::new( + crate::etw_consumer::AttributionIndex::new(), + )); + let etw_session = if config.etw_audit { + match crate::etw_consumer::start_session(attribution.clone()) { + Ok(session) => Some(session), + Err(e) => { + warn!(error = %e, "MXC ETW audit consumer failed to start; continuing without it"); + None + } + } + } else { + None + }; + Self { invoker, config, @@ -280,6 +316,8 @@ impl MxcComputeBackend { // mapper before any MXC lifecycle side effects begin. policy_mapper: Arc::new(EmbeddedPolicyMapper), pending_policies: Arc::new(Mutex::new(HashMap::new())), + etw_session, + attribution, } } @@ -420,6 +458,7 @@ impl MxcComputeBackend { let config = self.config.clone(); let registry = self.registry.clone(); let watch_tx = self.watch_tx.clone(); + let attribution = self.attribution.clone(); let sandbox = sandbox.clone(); tokio::spawn(async move { run_lifecycle( @@ -427,6 +466,7 @@ impl MxcComputeBackend { config, registry, watch_tx, + attribution, sandbox, sandbox_config, mapped, @@ -558,6 +598,9 @@ impl MxcComputeBackend { let mut registry = self.registry.lock().await; if registry.remove(sandbox_id).is_some() { + if let Ok(mut idx) = self.attribution.lock() { + idx.forget(sandbox_id); + } let _ = self.watch_tx.send(deleted_event(sandbox_id.to_string())); return Ok(true); } @@ -619,6 +662,7 @@ async fn run_lifecycle( config: MxcComputeConfig, registry: Arc>>, watch_tx: Arc>, + attribution: Arc>, sandbox: DriverSandbox, sandbox_config: MxcSandboxConfig, mapped: MappedConfig, @@ -722,6 +766,16 @@ async fn run_lifecycle( }; info!(sandbox = %sandbox_name, command = %command_line, backend = ?config.backend, "MXC agent launched"); + // Seed ETW attribution: the wxc-exec pid we just spawned is the + // collision-proof anchor that ties the `Sandboxing` provider's events back + // to this `sandbox_id` (command line is a fallback matcher). No-op unless + // the ETW consumer is running. + if let Some(pid) = child.id() { + if let Ok(mut idx) = attribution.lock() { + idx.register_launch(&sandbox_id, &sandbox_name, pid, &command_line); + } + } + let ready_sandbox = make_sandbox_with_condition( &sandbox, &DriverCondition { diff --git a/crates/openshell-driver-mxc/src/etw_consumer.rs b/crates/openshell-driver-mxc/src/etw_consumer.rs new file mode 100644 index 0000000000..6db6009a72 --- /dev/null +++ b/crates/openshell-driver-mxc/src/etw_consumer.rs @@ -0,0 +1,1235 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Real-time ETW → OCSF audit consumer for MXC (Plane A). +//! +//! MXC does not emit its own ETW; the events we consume are produced by the OS +//! **Sandboxing** TraceLogging provider (`{f6ec123e-…}`) as a side effect of the +//! AppContainer / `processcontainer` operations MXC drives. This module runs one +//! process-wide real-time trace session, decodes events via TDH, and (in later +//! checkpoints) attributes each to an OpenShell `sandbox_id` and emits OCSF +//! through the gateway's tracing sink (`TracingLogBus`). +//! +//! Two responsibilities are kept behind a clean internal seam so a future +//! crate-extraction is a move-file, not a rewrite: +//! 1. **capture + decode** (this module's `unsafe` TDH/ETW code) → produces a +//! neutral [`DecodedEtwEvent`]. Knows nothing about OCSF or the registry. +//! 2. **attribute + map + emit** (the `handler` closure passed to +//! [`start_session`]) → `DecodedEtwEvent` → registry lookup → OCSF. +//! +//! Ported from MXC's reference consumer +//! (`msft-mxc/src/tools/mxc_diagnostic_console/src/etw.rs`), trimmed to Plane A +//! (Sandboxing provider only — the Kernel-General provider needs privilege our +//! service account does not have and is not required for Plane A). +//! +//! Checkpoint 2: capture + decode only. `start_session`'s handler currently just +//! logs decoded events at `debug`. Attribution + OCSF mapping land in later +//! checkpoints, without touching the capture/decode seam below. + +// This module is a thin, self-contained wrapper over the Windows ETW/TDH C API, +// which is unavoidably `unsafe`. The workspace lint `unsafe_code = "warn"` is +// allowed here (and only here) rather than annotating dozens of FFI blocks; the +// unsafe surface is confined to this file behind the safe `start_session` API. +#![allow(unsafe_code)] +// Scaffold: OCSF emit/context helpers are unused until checkpoint 3. +#![allow(dead_code)] +// The following pedantic/nursery lints are inherent to decoding raw ETW records +// against Windows structs and are allowed for this FFI module only: +// - pointer casts over the `EVENT_TRACE_PROPERTIES` / TDH buffers (the documented +// Win32 pattern of a `Vec` backing a header struct), +// - width/sign casts on fixed, small size/level values, +// - GUID/brace text in doc comments. +#![allow( + clippy::cast_ptr_alignment, + clippy::cast_possible_truncation, + clippy::cast_possible_wrap, + clippy::borrow_as_ptr, + clippy::ptr_as_ptr, + clippy::match_same_arms, + clippy::redundant_pub_crate, + clippy::doc_markdown +)] + +use std::collections::HashMap; +use std::ffi::c_void; +use std::sync::mpsc; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; + +use windows::Win32::Foundation::WIN32_ERROR; +use windows::Win32::System::Diagnostics::Etw::{ + CONTROLTRACE_HANDLE, CloseTrace, ControlTraceW, EVENT_HEADER, EVENT_HEADER_EXTENDED_DATA_ITEM, + EVENT_PROPERTY_INFO, EVENT_RECORD, EVENT_TRACE_CONTROL_STOP, EVENT_TRACE_LOGFILEW, + EVENT_TRACE_PROPERTIES, EVENT_TRACE_REAL_TIME_MODE, EnableTraceEx2, OpenTraceW, + PROCESS_TRACE_MODE_EVENT_RECORD, PROCESS_TRACE_MODE_REAL_TIME, ProcessTrace, StartTraceW, + TRACE_EVENT_INFO, TRACE_LEVEL_VERBOSE, TdhGetEventInformation, WNODE_FLAG_TRACED_GUID, +}; +use windows::core::{GUID, PCWSTR, PWSTR}; + +use openshell_ocsf::{ + ActionId, ActivityId, AppLifecycleBuilder, ConfigStateChangeBuilder, DetectionFindingBuilder, + DispositionId, FindingInfo, LaunchTypeId, OcsfEvent, Process, ProcessActivityBuilder, + SandboxContext, SecurityLevelId, SeverityId, StateId, StatusId, +}; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/// OS ProcessModel/Sandboxing TraceLogging provider — the Plane-A source. +/// `{f6ec123e-314e-400b-9e0a-151365e23083}`. +pub(crate) const SANDBOXING_PROVIDER_GUID: GUID = + GUID::from_u128(0xf6ec123e_314e_400b_9e0a_151365e23083); + +/// Our real-time session name (distinct from MXC's diagnostic console session). +const SESSION_NAME: &str = "OpenShell-MXC-ETW"; + +/// `EVENT_CONTROL_CODE_ENABLE_PROVIDER`. +const EVENT_CONTROL_CODE_ENABLE_PROVIDER: u32 = 1; + +/// `TdhGetEventInformation` sizing probe returns this when asking for the buffer size. +const ERROR_INSUFFICIENT_BUFFER: u32 = 122; + +// TDH InType constants for property decoding. +const TDH_INTYPE_UNICODESTRING: u16 = 1; +const TDH_INTYPE_ANSISTRING: u16 = 2; +const TDH_INTYPE_INT8: u16 = 3; +const TDH_INTYPE_UINT8: u16 = 4; +const TDH_INTYPE_INT16: u16 = 5; +const TDH_INTYPE_UINT16: u16 = 6; +const TDH_INTYPE_INT32: u16 = 7; +const TDH_INTYPE_UINT32: u16 = 8; +const TDH_INTYPE_INT64: u16 = 9; +const TDH_INTYPE_UINT64: u16 = 10; +const TDH_INTYPE_FLOAT: u16 = 11; +const TDH_INTYPE_DOUBLE: u16 = 12; +const TDH_INTYPE_BOOLEAN: u16 = 13; +const TDH_INTYPE_GUID: u16 = 15; +const TDH_INTYPE_POINTER: u16 = 16; +const TDH_INTYPE_FILETIME: u16 = 17; +const TDH_INTYPE_HEXINT32: u16 = 20; +const TDH_INTYPE_HEXINT64: u16 = 21; + +// --------------------------------------------------------------------------- +// Neutral decoded event (the capture/decode → attribute/map seam) +// --------------------------------------------------------------------------- + +/// TraceLogging activity opcodes we care about. +const OPCODE_START: u8 = 1; +const OPCODE_STOP: u8 = 2; + +/// An owned, `Send` copy of a raw ETW event record, captured in the callback so +/// the (slow) TDH decode happens off the real-time `ProcessTrace` pump thread. +/// +/// Decoding inline in the callback made the pump fall behind during the +/// sandbox-create burst, and ETW silently dropped mid-burst events into +/// `RealTimeBuffersLost`. The callback now does only cheap byte copies and hands +/// off; the consumer thread reconstructs an [`EVENT_RECORD`] over these owned +/// buffers and decodes at leisure. TraceLogging events carry their schema in the +/// extended-data items, so those are deep-copied too (not just `UserData`). +struct RawEtwEvent { + header: EVENT_HEADER, + user_data: Vec, + /// Extended-data item headers (their `DataPtr` is re-pointed at `ext_bufs` + /// before decode). + ext_items: Vec, + /// Owned backing buffers for each extended-data item, index-aligned with + /// `ext_items`. + ext_bufs: Vec>, +} + +// SAFETY: every field is either a `Vec` or a POD Windows struct whose only +// address-like field (`EVENT_HEADER_EXTENDED_DATA_ITEM::DataPtr`, a `u64`) is +// re-pointed at our owned buffers on the consumer thread before use. No borrowed +// kernel pointers survive the callback, so this is sound to move across threads. +unsafe impl Send for RawEtwEvent {} + +/// A decoded ETW event, independent of OCSF and the driver registry. +#[derive(Debug, Clone)] +pub(crate) struct DecodedEtwEvent { + /// Provider that emitted the event. + pub provider: GUID, + /// TraceLogging event id. + pub event_id: u16, + /// Event level (1=crit … 5=verbose). + pub level: u8, + /// Activity opcode: 1=Start, 2=Stop, 0=Info (plain event). + pub opcode: u8, + /// Emitting process id. + pub process_id: u32, + /// ETW activity id (event header) — the cross-process/cross-event correlator + /// for payload-keyless events like `SandboxConfig`. + pub activity_id: GUID, + /// Event/task name from TDH, if present. + pub event_name: Option, + /// Top-level properties as `(name, value)`; string values keep TDH's quotes. + pub props: Vec<(String, String)>, +} + +impl DecodedEtwEvent { + /// Raw property value (may be quoted for string types), first match wins. + pub fn get(&self, key: &str) -> Option<&str> { + self.props + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.as_str()) + } + + /// Property value with surrounding double-quotes trimmed (for string types). + pub fn get_unquoted(&self, key: &str) -> Option { + self.get(key) + .map(|v| v.trim_matches('"').to_string()) + .filter(|s| !s.is_empty()) + } + + /// The MXC sandbox identity, if this event carries a non-empty one. + pub fn identity(&self) -> Option { + self.get_unquoted("identity") + } + + /// The Correlation-Vector base (`.` → ``) from `__TlgCV__` or + /// `correlationVector`, if present. A cross-event correlator MXC stamps on + /// most (not all) events. + pub fn cv_base(&self) -> Option { + self.get_unquoted("__TlgCV__") + .or_else(|| self.get_unquoted("correlationVector")) + .map(|cv| cv.split('.').next().unwrap_or(&cv).to_string()) + .filter(|s| !s.is_empty()) + } + + /// Compact `name { k=v, k=v }` rendering for debug logging. + pub fn summary(&self) -> String { + let name = self.event_name.as_deref().unwrap_or(""); + if self.props.is_empty() { + format!("{name} (id={})", self.event_id) + } else { + let joined: Vec = self.props.iter().map(|(k, v)| format!("{k}={v}")).collect(); + format!("{name} (id={}) {{ {} }}", self.event_id, joined.join(", ")) + } + } +} + +// --------------------------------------------------------------------------- +// Session handle (RAII) +// --------------------------------------------------------------------------- + +/// A running real-time ETW session plus its worker threads. Dropping (or calling +/// [`EtwSession::stop`]) stops the session and joins the threads. +pub(crate) struct EtwSession { + handle: u64, + pump_thread: Option>, + consumer_thread: Option>, +} + +impl EtwSession { + /// Stop the session and join worker threads. Idempotent. + pub fn stop(&mut self) { + if self.handle != 0 { + stop_session(self.handle); + self.handle = 0; + } + // ControlTraceW(STOP) makes ProcessTrace return → the pump thread ends and + // drops the boxed Sender → the consumer thread's `for ev in rx` ends. + if let Some(t) = self.pump_thread.take() { + let _ = t.join(); + } + if let Some(t) = self.consumer_thread.take() { + let _ = t.join(); + } + } +} + +impl Drop for EtwSession { + fn drop(&mut self) { + self.stop(); + } +} + +/// Wrapper to move a raw `Sender` pointer across the thread boundary into the +/// blocking `ProcessTrace` worker. SAFETY: the boxed `Sender` lives until the +/// worker reclaims it after `ProcessTrace` returns. +struct SendPtr(*mut mpsc::Sender); +unsafe impl Send for SendPtr {} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Start the real-time ETW session on the Sandboxing provider. Every decoded +/// event is attributed (via `index`) and mapped to OCSF on a dedicated consumer +/// thread. The driver seeds `index` (pid → sandbox_id) as it launches sandboxes. +/// +/// Returns an [`EtwSession`] that must be kept alive; dropping it stops capture. +pub(crate) fn start_session(index: Arc>) -> Result { + cleanup_stale_session(); + + let handle = start_trace_session()?; + enable_provider(handle)?; + + let (tx, rx) = mpsc::channel::(); + + let consumer_thread = std::thread::Builder::new() + .name("etw-ocsf-consumer".into()) + .spawn(move || { + // Decode off the pump thread: the callback only copies bytes, so the + // real-time buffers drain fast and the create burst isn't dropped. + for mut raw in rx { + match decode_raw(&mut raw) { + Some(ev) => process_event(&index, ev), + None => tracing::debug!( + target: "mxc_etw", + id = raw.header.EventDescriptor.Id, + opcode = raw.header.EventDescriptor.Opcode, + pid = raw.header.ProcessId, + "TDH decode failed for event" + ), + } + } + }) + .map_err(|e| { + stop_session(handle); + format!("failed to spawn ETW consumer thread: {e}") + })?; + + let send_ptr = SendPtr(Box::into_raw(Box::new(tx))); + let pump_thread = std::thread::Builder::new() + .name("etw-ocsf-pump".into()) + .spawn(move || process_trace_loop(send_ptr)) + .map_err(|e| { + stop_session(handle); + format!("failed to spawn ETW pump thread: {e}") + })?; + + tracing::info!( + session = SESSION_NAME, + "MXC ETW→OCSF consumer started (Sandboxing provider)" + ); + + Ok(EtwSession { + handle, + pump_thread: Some(pump_thread), + consumer_thread: Some(consumer_thread), + }) +} + +// --------------------------------------------------------------------------- +// Session management +// --------------------------------------------------------------------------- + +fn session_name_wide() -> Vec { + SESSION_NAME + .encode_utf16() + .chain(std::iter::once(0)) + .collect() +} + +fn alloc_properties_buf() -> Vec { + let props_size = size_of::(); + let name_wide_len = SESSION_NAME.encode_utf16().count() + 1; + let name_bytes = name_wide_len * 2; + let total = props_size + name_bytes + 2; + + let mut buf = vec![0u8; total]; + let props = buf.as_mut_ptr().cast::(); + unsafe { + (*props).Wnode.BufferSize = total as u32; + (*props).LoggerNameOffset = props_size as u32; + (*props).LogFileNameOffset = (props_size + name_bytes) as u32; + } + buf +} + +fn start_trace_session() -> Result { + let name = session_name_wide(); + let mut buf = alloc_properties_buf(); + let props = buf.as_mut_ptr().cast::(); + + unsafe { + (*props).Wnode.Flags = WNODE_FLAG_TRACED_GUID; + (*props).Wnode.ClientContext = 1; // QPC timestamps + (*props).LogFileMode = EVENT_TRACE_REAL_TIME_MODE; + // ETW uses per-processor buffers. A short sandbox-create burst can leave + // a low-volume buffer on one CPU unflushed until the session stops, + // intermittently dropping mid-stream events (e.g. SandboxConfig). A 1s + // flush timer forces every per-CPU buffer to deliver promptly; the + // buffer sizing gives headroom for the create burst. + (*props).BufferSize = 64; // KB per buffer + (*props).MinimumBuffers = 8; + (*props).MaximumBuffers = 64; + (*props).FlushTimer = 1; // seconds + } + + let mut handle = CONTROLTRACE_HANDLE::default(); + let status = unsafe { StartTraceW(&mut handle, PCWSTR(name.as_ptr()), props) }; + + if status != WIN32_ERROR(0) { + return Err(format!( + "StartTraceW failed: error {} (needs 'Performance Log Users' or admin)", + status.0 + )); + } + + Ok(handle.Value) +} + +fn enable_provider(session_handle: u64) -> Result<(), String> { + let h = CONTROLTRACE_HANDLE { + Value: session_handle, + }; + + let status = unsafe { + EnableTraceEx2( + h, + &SANDBOXING_PROVIDER_GUID, + EVENT_CONTROL_CODE_ENABLE_PROVIDER, + TRACE_LEVEL_VERBOSE as u8, + 0xFFFF_FFFF_FFFF_FFFF, // all keywords + 0, + 0, + None, + ) + }; + + if status != WIN32_ERROR(0) { + stop_session(session_handle); + return Err(format!( + "EnableTraceEx2 (Sandboxing provider) failed: error {}", + status.0 + )); + } + + Ok(()) +} + +fn stop_session(handle: u64) { + let name = session_name_wide(); + let mut buf = alloc_properties_buf(); + let props = buf.as_mut_ptr().cast::(); + let h = CONTROLTRACE_HANDLE { Value: handle }; + + unsafe { + let status = ControlTraceW(h, PCWSTR(name.as_ptr()), props, EVENT_TRACE_CONTROL_STOP); + // On a successful STOP the kernel fills the properties with final session + // stats. Surface EventsLost so lossy captures are never silent (an audit + // trail that silently drops events is worse than one that flags gaps). + if status == WIN32_ERROR(0) { + // EventsLost = kernel buffer overruns; RealTimeBuffersLost/LogBuffersLost + // = the real-time delivery queue overflowing because the consumer fell + // behind. The latter is what a slow callback causes, so surface all + // three — an audit trail that silently drops events is worse than one + // that flags gaps. + let events_lost = (*props).EventsLost; + let rt_lost = (*props).RealTimeBuffersLost; + let log_lost = (*props).LogBuffersLost; + if events_lost > 0 || rt_lost > 0 || log_lost > 0 { + tracing::warn!( + events_lost, + realtime_buffers_lost = rt_lost, + log_buffers_lost = log_lost, + session = SESSION_NAME, + "ETW session lost events (increase buffers / speed up consumer)" + ); + } else { + tracing::debug!(session = SESSION_NAME, "ETW session stopped; 0 events lost"); + } + } + } +} + +/// Best-effort stop of a same-named session left behind by a crashed run, so +/// `StartTraceW` doesn't fail with `ERROR_ALREADY_EXISTS`. +fn cleanup_stale_session() { + let name = session_name_wide(); + let mut buf = alloc_properties_buf(); + let props = buf.as_mut_ptr().cast::(); + + unsafe { + let _ = ControlTraceW( + CONTROLTRACE_HANDLE::default(), + PCWSTR(name.as_ptr()), + props, + EVENT_TRACE_CONTROL_STOP, + ); + } +} + +// --------------------------------------------------------------------------- +// ProcessTrace loop (dedicated blocking thread) +// --------------------------------------------------------------------------- + +#[allow(clippy::field_reassign_with_default)] +fn process_trace_loop(send_ptr: SendPtr) { + let tx_ptr = send_ptr.0; + let mut name = session_name_wide(); + + let mut logfile = EVENT_TRACE_LOGFILEW::default(); + logfile.LoggerName = PWSTR(name.as_mut_ptr()); + logfile.Anonymous1.ProcessTraceMode = + PROCESS_TRACE_MODE_REAL_TIME | PROCESS_TRACE_MODE_EVENT_RECORD; + logfile.Anonymous2.EventRecordCallback = Some(event_record_callback); + logfile.Context = tx_ptr.cast::(); + + let trace_handle = unsafe { OpenTraceW(&mut logfile) }; + if trace_handle.Value == u64::MAX { + tracing::error!(err = %std::io::Error::last_os_error(), "ETW OpenTraceW failed"); + // Reclaim the boxed Sender so the consumer thread's channel closes. + unsafe { drop(Box::from_raw(tx_ptr)) }; + return; + } + + let _ = unsafe { ProcessTrace(&[trace_handle], None, None) }; + + unsafe { + let _ = CloseTrace(trace_handle); + drop(Box::from_raw(tx_ptr)); + } +} + +unsafe extern "system" fn event_record_callback(event_record: *mut EVENT_RECORD) { + let event = unsafe { &*event_record }; + // Hot path — keep it minimal (decode runs on the consumer thread). We only + // enabled the Sandboxing provider, but guard anyway. + if event.EventHeader.ProviderId != SANDBOXING_PROVIDER_GUID { + return; + } + + // Hot path: copy raw bytes only, then hand off. No TDH decode here — keeping + // this callback cheap is what stops ETW dropping the create burst. + let tx = unsafe { &*(event.UserContext as *const mpsc::Sender) }; + let raw = unsafe { copy_raw(event_record) }; + let _ = tx.send(raw); +} + +/// Deep-copy a kernel `EVENT_RECORD` into an owned, `Send` [`RawEtwEvent`]. +/// Runs in the ETW callback, so it does the minimum: byte copies, no decode. +unsafe fn copy_raw(event_record: *const EVENT_RECORD) -> RawEtwEvent { + let ev = unsafe { &*event_record }; + let header = ev.EventHeader; + + let ulen = ev.UserDataLength as usize; + let user_data = if ev.UserData.is_null() || ulen == 0 { + Vec::new() + } else { + unsafe { std::slice::from_raw_parts(ev.UserData.cast::(), ulen) }.to_vec() + }; + + let ext_count = ev.ExtendedDataCount as usize; + let mut ext_items = Vec::with_capacity(ext_count); + let mut ext_bufs = Vec::with_capacity(ext_count); + if !ev.ExtendedData.is_null() { + for i in 0..ext_count { + let item = unsafe { *ev.ExtendedData.add(i) }; + let dsize = item.DataSize as usize; + let buf = if item.DataPtr == 0 || dsize == 0 { + Vec::new() + } else { + unsafe { std::slice::from_raw_parts(item.DataPtr as *const u8, dsize) }.to_vec() + }; + ext_items.push(item); + ext_bufs.push(buf); + } + } + + RawEtwEvent { + header, + user_data, + ext_items, + ext_bufs, + } +} + +/// Reconstruct an [`EVENT_RECORD`] over the owned buffers and TDH-decode it. +/// Runs on the consumer thread (off the real-time pump). +#[allow(clippy::field_reassign_with_default)] +fn decode_raw(raw: &mut RawEtwEvent) -> Option { + // Re-point each extended-data item at our owned copy (TraceLogging schema + // lives here, so TDH must be able to read it). + for (item, buf) in raw.ext_items.iter_mut().zip(raw.ext_bufs.iter()) { + item.DataPtr = if buf.is_empty() { + 0 + } else { + buf.as_ptr() as u64 + }; + } + + let mut rec = EVENT_RECORD::default(); + rec.EventHeader = raw.header; + rec.UserDataLength = u16::try_from(raw.user_data.len()).unwrap_or(u16::MAX); + rec.UserData = if raw.user_data.is_empty() { + std::ptr::null_mut() + } else { + raw.user_data.as_ptr() as *mut c_void + }; + rec.ExtendedDataCount = u16::try_from(raw.ext_items.len()).unwrap_or(u16::MAX); + rec.ExtendedData = if raw.ext_items.is_empty() { + std::ptr::null_mut() + } else { + raw.ext_items.as_mut_ptr() + }; + + decode_event(std::ptr::addr_of_mut!(rec)) +} + +// --------------------------------------------------------------------------- +// Event decoding (TDH) +// --------------------------------------------------------------------------- + +/// Decode a raw event record into a neutral [`DecodedEtwEvent`] via TDH. +/// Returns `None` only when TDH decoding fails entirely. +fn decode_event(event_record: *mut EVENT_RECORD) -> Option { + let mut buf_size: u32 = 0; + let status = unsafe { TdhGetEventInformation(event_record, None, None, &mut buf_size) }; + if status != ERROR_INSUFFICIENT_BUFFER { + return None; + } + + let mut buffer = vec![0u8; buf_size as usize]; + let info_ptr = buffer.as_mut_ptr().cast::(); + let status = + unsafe { TdhGetEventInformation(event_record, None, Some(info_ptr), &mut buf_size) }; + if status != 0 { + return None; + } + + let info = unsafe { &*info_ptr }; + + let event_name_offset = unsafe { info.Anonymous1.EventNameOffset }; + let event_name = wide_str_at(&buffer, event_name_offset) + .or_else(|| wide_str_at(&buffer, info.TaskNameOffset)) + .filter(|s| !s.is_empty()); + + let header = unsafe { &(*event_record).EventHeader }; + let props = decode_properties(&buffer, info, event_record); + + Some(DecodedEtwEvent { + provider: header.ProviderId, + event_id: header.EventDescriptor.Id, + level: header.EventDescriptor.Level, + opcode: header.EventDescriptor.Opcode, + process_id: header.ProcessId, + activity_id: header.ActivityId, + event_name, + props, + }) +} + +fn decode_properties( + info_buf: &[u8], + info: &TRACE_EVENT_INFO, + event_record: *mut EVENT_RECORD, +) -> Vec<(String, String)> { + let event = unsafe { &*event_record }; + let user_data = event.UserData as *const u8; + let user_data_len = event.UserDataLength as usize; + + if user_data.is_null() || user_data_len == 0 { + return Vec::new(); + } + + let prop_count = info.TopLevelPropertyCount as usize; + let mut results = Vec::with_capacity(prop_count); + let mut offset: usize = 0; + + for i in 0..prop_count { + let prop_info = unsafe { + let base = + std::ptr::addr_of!(info.EventPropertyInfoArray) as *const EVENT_PROPERTY_INFO; + &*base.add(i) + }; + + let prop_name = + wide_str_at(info_buf, prop_info.NameOffset).unwrap_or_else(|| format!("prop{i}")); + + // PropertyStruct flag: the header holds no data, but its child members + // occupy space in the user-data buffer, so decode+skip each to keep + // `offset` in sync. + if prop_info.Flags.0 & 1 != 0 { + let num_members = + unsafe { prop_info.Anonymous1.structType.NumOfStructMembers } as usize; + let start_index = unsafe { prop_info.Anonymous1.structType.StructStartIndex } as usize; + + for j in 0..num_members { + let child_prop = unsafe { + let base = std::ptr::addr_of!(info.EventPropertyInfoArray) + as *const EVENT_PROPERTY_INFO; + &*base.add(start_index + j) + }; + let child_in_type = unsafe { child_prop.Anonymous1.nonStructType.InType }; + let child_length = unsafe { child_prop.Anonymous3.length } as usize; + let remaining = user_data_len.saturating_sub(offset); + let data_ptr = if remaining > 0 { + unsafe { user_data.add(offset) } + } else { + std::ptr::null() + }; + let (_, consumed) = + format_property_value(child_in_type, child_length, data_ptr, remaining); + offset += consumed; + } + + results.push((prop_name, "".to_string())); + continue; + } + + let in_type = unsafe { prop_info.Anonymous1.nonStructType.InType }; + let prop_length = unsafe { prop_info.Anonymous3.length } as usize; + + let remaining = user_data_len.saturating_sub(offset); + let data_ptr = if remaining > 0 { + unsafe { user_data.add(offset) } + } else { + std::ptr::null() + }; + + let (value_str, consumed) = + format_property_value(in_type, prop_length, data_ptr, remaining); + offset += consumed; + results.push((prop_name, value_str)); + } + + results +} + +/// Decode a single property value, returning `(rendered, bytes_consumed)`. +fn format_property_value( + in_type: u16, + declared_length: usize, + data: *const u8, + available: usize, +) -> (String, usize) { + if data.is_null() || available == 0 { + return ("".to_string(), 0); + } + + match in_type { + TDH_INTYPE_UNICODESTRING => { + let max_wchars = available / 2; + let wchars = unsafe { std::slice::from_raw_parts(data.cast::(), max_wchars) }; + let len = wchars.iter().position(|&c| c == 0).unwrap_or(max_wchars); + let s = String::from_utf16_lossy(&wchars[..len]); + let consumed = (len + 1).min(max_wchars) * 2; + (format!("\"{s}\""), consumed) + } + TDH_INTYPE_ANSISTRING => { + let bytes = unsafe { std::slice::from_raw_parts(data, available) }; + let len = bytes.iter().position(|&b| b == 0).unwrap_or(available); + let s = String::from_utf8_lossy(&bytes[..len]); + let consumed = (len + 1).min(available); + (format!("\"{s}\""), consumed) + } + TDH_INTYPE_INT8 if available >= 1 => ((unsafe { *data } as i8).to_string(), 1), + TDH_INTYPE_UINT8 if available >= 1 => ((unsafe { *data }).to_string(), 1), + TDH_INTYPE_INT16 if available >= 2 => { + (i16::from_le_bytes(read_bytes::<2>(data)).to_string(), 2) + } + TDH_INTYPE_UINT16 if available >= 2 => { + (u16::from_le_bytes(read_bytes::<2>(data)).to_string(), 2) + } + TDH_INTYPE_INT32 if available >= 4 => { + (i32::from_le_bytes(read_bytes::<4>(data)).to_string(), 4) + } + TDH_INTYPE_UINT32 if available >= 4 => { + (u32::from_le_bytes(read_bytes::<4>(data)).to_string(), 4) + } + TDH_INTYPE_INT64 if available >= 8 => { + (i64::from_le_bytes(read_bytes::<8>(data)).to_string(), 8) + } + TDH_INTYPE_UINT64 if available >= 8 => { + (u64::from_le_bytes(read_bytes::<8>(data)).to_string(), 8) + } + TDH_INTYPE_FLOAT if available >= 4 => ( + format!("{:.4}", f32::from_le_bytes(read_bytes::<4>(data))), + 4, + ), + TDH_INTYPE_DOUBLE if available >= 8 => ( + format!("{:.4}", f64::from_le_bytes(read_bytes::<8>(data))), + 8, + ), + TDH_INTYPE_BOOLEAN if available >= 4 => ( + (i32::from_le_bytes(read_bytes::<4>(data)) != 0).to_string(), + 4, + ), + TDH_INTYPE_GUID if available >= 16 => { + let b = unsafe { std::slice::from_raw_parts(data, 16) }; + let d1 = u32::from_le_bytes([b[0], b[1], b[2], b[3]]); + let d2 = u16::from_le_bytes([b[4], b[5]]); + let d3 = u16::from_le_bytes([b[6], b[7]]); + let s = format!( + "{{{d1:08x}-{d2:04x}-{d3:04x}-{:02x}{:02x}-\ + {:02x}{:02x}{:02x}{:02x}{:02x}{:02x}}}", + b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15] + ); + (s, 16) + } + TDH_INTYPE_HEXINT32 if available >= 4 => ( + format!("0x{:08X}", u32::from_le_bytes(read_bytes::<4>(data))), + 4, + ), + TDH_INTYPE_HEXINT64 if available >= 8 => ( + format!("0x{:016X}", u64::from_le_bytes(read_bytes::<8>(data))), + 8, + ), + TDH_INTYPE_POINTER if available >= 8 => ( + format!("0x{:016X}", u64::from_le_bytes(read_bytes::<8>(data))), + 8, + ), + TDH_INTYPE_FILETIME if available >= 8 => ( + format!( + "FILETIME(0x{:016X})", + u64::from_le_bytes(read_bytes::<8>(data)) + ), + 8, + ), + _ => { + let len = if declared_length > 0 { + declared_length.min(available) + } else { + available.min(32) + }; + let bytes = unsafe { std::slice::from_raw_parts(data, len) }; + let hex: String = bytes + .iter() + .map(|b| format!("{b:02X}")) + .collect::>() + .join(" "); + (hex, len) + } + } +} + +// --------------------------------------------------------------------------- +// Attribution: MXC ETW event → OpenShell sandbox_id +// --------------------------------------------------------------------------- + +/// Runtime index that maps MXC's uneven ETW correlators back to an OpenShell +/// `sandbox_id`. Shared (`Arc>`) between the driver (which seeds +/// `pid → sandbox_id` as it spawns wxc-exec) and the ETW consumer thread. +/// +/// Attribution chain (grounded in the live `Sandboxing` capture): +/// - **pid anchor** — the wxc-exec pid we spawn is unique and driver-owned; it +/// emits `CreateProcessInSandbox`, which also carries `identity` + CV. +/// - from there we learn `identity → sandbox_id` and (`SandboxEngineCreate`) +/// `activity_id → sandbox_id`, so the payload-keyless `SandboxConfig` +/// (no identity/CV) resolves via the ETW `ActivityId` it shares. +/// - `commandLine` and a per-pid "last resolved" value are fallbacks. +#[derive(Default)] +pub(crate) struct AttributionIndex { + by_pid: HashMap, + by_identity: HashMap, + by_activity: HashMap, + by_cv: HashMap, + by_cmd: HashMap, + last_pid_sid: HashMap, + names: HashMap, + /// Sandboxes for which a lifecycle [6002] row has already been emitted, so + /// the two redundant create events don't double-count. + lifecycle_emitted: std::collections::HashSet, +} + +impl AttributionIndex { + pub fn new() -> Self { + Self::default() + } + + /// Register a launched sandbox. `wxc_pid` (the process we spawned) is the + /// collision-proof anchor; `command_line` is a fallback matcher. + pub fn register_launch( + &mut self, + sandbox_id: &str, + sandbox_name: &str, + wxc_pid: u32, + command_line: &str, + ) { + self.by_pid.insert(wxc_pid, sandbox_id.to_string()); + if !command_line.is_empty() { + self.by_cmd + .insert(command_line.to_string(), sandbox_id.to_string()); + } + self.names + .insert(sandbox_id.to_string(), sandbox_name.to_string()); + } + + /// Drop all keys for a finished sandbox to bound memory. + pub fn forget(&mut self, sandbox_id: &str) { + self.by_pid.retain(|_, v| v != sandbox_id); + self.by_identity.retain(|_, v| v != sandbox_id); + self.by_activity.retain(|_, v| v != sandbox_id); + self.by_cv.retain(|_, v| v != sandbox_id); + self.by_cmd.retain(|_, v| v != sandbox_id); + self.last_pid_sid.retain(|_, v| v != sandbox_id); + self.names.remove(sandbox_id); + self.lifecycle_emitted.remove(sandbox_id); + } + + /// Returns `true` the first time a lifecycle row should be emitted for this + /// sandbox. MXC emits two redundant create events (`SandboxEngineCreate` and + /// `SandboxCreateWithPolicyEnforcement`) and ETW drops them interchangeably + /// under load, so we anchor on whichever arrives first and dedupe here. + fn take_lifecycle_once(&mut self, sandbox_id: &str) -> bool { + self.lifecycle_emitted.insert(sandbox_id.to_string()) + } + + fn name_of(&self, sandbox_id: &str) -> String { + self.names + .get(sandbox_id) + .cloned() + .unwrap_or_else(|| sandbox_id.to_string()) + } + + /// Resolve an event to a `sandbox_id` via any known key, then cross-link the + /// other keys it carries so later keyless events attribute correctly. + fn resolve(&mut self, ev: &DecodedEtwEvent) -> Option { + let identity = ev.identity(); + let cv = ev.cv_base(); + let activity = guid_key(&ev.activity_id); + let cmd = ev.get_unquoted("commandLine"); + + let sid = self + .by_pid + .get(&ev.process_id) + .cloned() + .or_else(|| { + identity + .as_ref() + .and_then(|i| self.by_identity.get(i).cloned()) + }) + .or_else(|| { + activity + .as_ref() + .and_then(|a| self.by_activity.get(a).cloned()) + }) + .or_else(|| cv.as_ref().and_then(|c| self.by_cv.get(c).cloned())) + .or_else(|| cmd.as_ref().and_then(|c| self.by_cmd.get(c).cloned())) + .or_else(|| self.last_pid_sid.get(&ev.process_id).cloned())?; + + if let Some(i) = identity { + self.by_identity.entry(i).or_insert_with(|| sid.clone()); + } + if let Some(c) = cv { + self.by_cv.entry(c).or_insert_with(|| sid.clone()); + } + if let Some(a) = activity { + self.by_activity.entry(a).or_insert_with(|| sid.clone()); + } + self.last_pid_sid.insert(ev.process_id, sid.clone()); + + Some(sid) + } +} + +/// Consumer-thread entry point: attribute one decoded event and, for the mapped +/// classes, emit an OCSF row into the gateway trail. Unmapped/unresolved events +/// are debug-logged (checkpoint-2 behaviour) so nothing is silently dropped. +fn process_event(index: &Mutex, ev: DecodedEtwEvent) { + // Activity STOP is the empty twin of START — never a distinct OCSF row. + if ev.opcode == OPCODE_STOP { + return; + } + + let (sandbox_id, sandbox_name) = { + let mut idx = index + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match idx.resolve(&ev) { + Some(sid) => { + let name = idx.name_of(&sid); + (sid, name) + } + None => { + drop(idx); + tracing::debug!(target: "mxc_etw", pid = ev.process_id, "unattributed {}", ev.summary()); + return; + } + } + }; + + // STOP twins are already filtered above, so activity events reaching here + // are STARTs. + match ev.event_name.as_deref().unwrap_or("") { + // Lifecycle [6002]: MXC emits two create events per sandbox — + // `SandboxEngineCreate` and `SandboxCreateWithPolicyEnforcement` — and + // ETW drops them interchangeably under buffer pressure (observed: one run + // keeps the former, the next keeps the latter). Anchor on whichever + // arrives first and dedupe so the row is emitted exactly once. + "SandboxEngineCreate" | "SandboxCreateWithPolicyEnforcement" + if ev.opcode == OPCODE_START => + { + let first = index + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take_lifecycle_once(&sandbox_id); + if first { + let ctx = etw_ctx(&sandbox_id, &sandbox_name); + emit_ocsf(&sandbox_id, map_lifecycle_create(&ctx, &sandbox_name)); + } + } + // Process [1007]: `CreateProcessInSandbox` carries the real agent command + // line + working directory. The activity fires once empty (probe) and + // once with the command — only emit for the populated one. + "CreateProcessInSandbox" if ev.opcode == OPCODE_START => { + if let Some(cmd) = ev.get_unquoted("commandLine") { + let ctx = etw_ctx(&sandbox_id, &sandbox_name); + emit_ocsf(&sandbox_id, map_process_launch(&ctx, &ev, &cmd)); + } else { + tracing::debug!(target: "mxc_etw", pid = ev.process_id, sandbox_id = %sandbox_id, "{}", ev.summary()); + } + } + // Config [5019]: several distinct config/hardening state changes. Each is + // a genuine audit-worthy config event; `SandboxConfig` is the richest but + // drops intermittently, so the reliably-captured hardening events + // (`Win32kLockdownApplied`, `ApplyUILimits`, `EnforceOsPolicy`) guarantee + // coverage. + "SandboxConfig" | "Win32kLockdownApplied" | "ApplyUILimits" | "EnforceOsPolicy" => { + let ctx = etw_ctx(&sandbox_id, &sandbox_name); + emit_ocsf(&sandbox_id, map_config_state(&ctx, &ev)); + } + // Finding [2004]: MXC surfaces WIL error/fallback activities during + // sandbox setup. Captured as informational (non-alert) findings so the + // audit trail records setup anomalies without crying wolf. + "ActivityError" | "FallbackError" => { + let ctx = etw_ctx(&sandbox_id, &sandbox_name); + emit_ocsf(&sandbox_id, map_finding(&ctx, &ev)); + } + _ => { + tracing::debug!(target: "mxc_etw", pid = ev.process_id, sandbox_id = %sandbox_id, "{}", ev.summary()); + } + } +} + +// --------------------------------------------------------------------------- +// OCSF mappers (checkpoint 3 subset: LIFECYCLE + CONFIG) +// --------------------------------------------------------------------------- + +/// `SandboxCreateWithPolicyEnforcement` (START) → Application Lifecycle [6002]. +fn map_lifecycle_create(ctx: &SandboxContext, sandbox_name: &str) -> OcsfEvent { + AppLifecycleBuilder::new(ctx) + .activity(ActivityId::Reset) // lifecycle label = "Start" + .severity(SeverityId::Informational) + .status(StatusId::Success) + .message(format!( + "MXC sandbox '{sandbox_name}' created with policy enforcement" + )) + .build() +} + +/// A sandbox config/hardening ETW event → Device Config State Change [5019]. +/// +/// Handles both `SandboxConfig` (full posture snapshot) and +/// `Win32kLockdownApplied` (agentic win32k lockdown): whichever config-ish +/// fields the event carries ride along as `unmapped`, and `security_level` +/// reflects any hardening signal present. +fn map_config_state(ctx: &SandboxContext, ev: &DecodedEtwEvent) -> OcsfEvent { + let flag = |k: &str| ev.get(k).map(|v| v == "1").unwrap_or(false); + let nonzero = |k: &str| ev.get(k).map(|v| v != "0").unwrap_or(false); + let hardened = flag("useLeastPrivilege") || flag("useAppContainer") || nonzero("agenticFlags"); + let security_level = if hardened { + SecurityLevelId::Secure + } else { + SecurityLevelId::Unknown + }; + + let message = match ev.event_name.as_deref().unwrap_or("") { + "Win32kLockdownApplied" => "MXC sandbox win32k lockdown applied", + "ApplyUILimits" => "MXC sandbox UI restrictions applied", + "EnforceOsPolicy" => "MXC sandbox OS policy enforced", + _ => "MXC sandbox OS policy configured", + }; + + let mut builder = ConfigStateChangeBuilder::new(ctx) + .state(StateId::Enabled, "configured") + .security_level(security_level) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .message(message); + + // Superset of config-ish fields across both event shapes; only present + // fields are attached. + for key in [ + "useAppContainer", + "integrityMode", + "integrityLevel", + "uiRestrictions", + "useLeastPrivilege", + "readWritePathsCount", + "readOnlyPathsCount", + "capabilities", + "agenticFlags", + "processId", + ] { + if let Some(v) = ev.get(key) { + builder = builder.unmapped(key, v.trim_matches('"').to_string()); + } + } + + builder.build() +} + +/// `CreateProcessInSandbox` (populated) → Process Activity [1007] "Launch". +fn map_process_launch(ctx: &SandboxContext, ev: &DecodedEtwEvent, cmd_line: &str) -> OcsfEvent { + // The created process's own pid isn't in this event (it appears later in + // `ProcessLaunched`); the emitting pid is the sandbox host (wxc-exec). + let proc = Process::new(&exe_name(cmd_line), 0).with_cmd_line(cmd_line); + let cwd = ev.get_unquoted("currentDirectory").unwrap_or_default(); + let cwd_suffix = if cwd.is_empty() { + String::new() + } else { + format!(" (cwd: {cwd})") + }; + ProcessActivityBuilder::new(ctx) + .activity(ActivityId::Open) // process label = "Launch" + .launch_type(LaunchTypeId::Spawn) + .action(ActionId::Allowed) + .disposition(DispositionId::Allowed) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .process(proc) + .actor_process(Process::new("wxc-exec", i64::from(ev.process_id))) + .message(format!( + "MXC sandbox launched process: {}{cwd_suffix}", + truncate(cmd_line, 160) + )) + .build() +} + +/// `ActivityError` / `FallbackError` → Detection Finding [2004] (informational). +fn map_finding(ctx: &SandboxContext, ev: &DecodedEtwEvent) -> OcsfEvent { + let kind = ev.event_name.as_deref().unwrap_or("SandboxError"); + let uid = ev + .cv_base() + .map(|cv| format!("{kind}:{cv}")) + .unwrap_or_else(|| format!("{kind}:{}", ev.process_id)); + DetectionFindingBuilder::new(ctx) + .activity(ActivityId::Open) // finding label = "Create" + .severity(SeverityId::Informational) + .is_alert(false) + .finding_info( + FindingInfo::new(&uid, &format!("MXC sandbox {kind}")) + .with_desc("MXC emitted a WIL error/fallback activity during sandbox setup."), + ) + .message(format!("MXC reported {kind} during sandbox setup")) + .build() +} + +/// Best-effort executable name from a command line: first whitespace-delimited +/// token, stripped of any directory prefix and surrounding quotes. +fn exe_name(cmd_line: &str) -> String { + let first = cmd_line + .trim() + .split_whitespace() + .next() + .unwrap_or("process") + .trim_matches('"'); + first + .rsplit(['\\', '/']) + .next() + .filter(|s| !s.is_empty()) + .unwrap_or("process") + .to_string() +} + +/// Truncate at a char boundary with an ellipsis (keeps shorthand tidy). +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + return s.to_string(); + } + let mut end = max; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + format!("{}…", &s[..end]) +} + +// --------------------------------------------------------------------------- +// OCSF emit helpers +// --------------------------------------------------------------------------- + +/// Emit an OCSF event so it lands in BOTH gateway output planes from one +/// tracing event: +/// - the **routing bus** (`TracingLogBus`) picks up the `sandbox_id` + `message` +/// fields → stdout shorthand + per-sandbox gRPC stream, and +/// - the **JSONL audit layer** (`OcsfJsonlLayer`, installed in +/// `openshell-server`'s subscriber) picks up the full structured `OcsfEvent` +/// from the thread-local bridge → durable `openshell-ocsf..log`. +/// +/// Before cp6 this fired a bare `tracing::info!` that never populated the +/// bridge, so the structured event was silently dropped and no JSONL was +/// written. `emit_ocsf_event_routed` does both jobs from a single dispatch. +fn emit_ocsf(sandbox_id: &str, event: OcsfEvent) { + openshell_ocsf::emit_ocsf_event_routed(sandbox_id, event); +} + +/// The gateway host's machine name, resolved once. This becomes `device.hostname` +/// in every emitted OCSF event, so the audit trail attributes activity to the +/// real box (e.g. `7F203-MXC-001`) rather than a static placeholder. `COMPUTERNAME` +/// is always set on Windows; we fall back to a sentinel only if it is somehow empty. +fn gateway_hostname() -> &'static str { + static HOSTNAME: std::sync::OnceLock = std::sync::OnceLock::new(); + HOSTNAME.get_or_init(|| { + std::env::var("COMPUTERNAME") + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "openshell-gateway".to_string()) + }) +} + +/// Build a per-event OCSF context (not the process-wide `ctx()` singleton, since +/// one gateway process hosts many sandboxes — wrinkle #1). +fn etw_ctx(sandbox_id: &str, sandbox_name: &str) -> SandboxContext { + SandboxContext { + sandbox_id: sandbox_id.to_string(), + sandbox_name: sandbox_name.to_string(), + container_image: "mxc/appcontainer".to_string(), + hostname: gateway_hostname().to_string(), + product_version: env!("CARGO_PKG_VERSION").to_string(), + proxy_ip: std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), + proxy_port: 0, + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Stable string key for an ETW `ActivityId` GUID, or `None` for the all-zero +/// GUID (which means "no activity" and must never be used as a correlation key). +fn guid_key(g: &GUID) -> Option { + if g.data1 == 0 && g.data2 == 0 && g.data3 == 0 && g.data4 == [0u8; 8] { + return None; + } + let tail: String = g.data4.iter().map(|b| format!("{b:02x}")).collect(); + Some(format!( + "{:08x}-{:04x}-{:04x}-{tail}", + g.data1, g.data2, g.data3 + )) +} + +fn read_bytes(ptr: *const u8) -> [u8; N] { + let mut out = [0u8; N]; + unsafe { + std::ptr::copy_nonoverlapping(ptr, out.as_mut_ptr(), N); + } + out +} + +fn wide_str_at(buf: &[u8], offset: u32) -> Option { + let off = offset as usize; + if off == 0 || off >= buf.len() { + return None; + } + + let remaining = &buf[off..]; + let max_wchars = remaining.len() / 2; + if max_wchars == 0 { + return None; + } + + let wchars = + unsafe { std::slice::from_raw_parts(remaining.as_ptr().cast::(), max_wchars) }; + let len = wchars.iter().position(|&c| c == 0).unwrap_or(max_wchars); + if len == 0 { + return None; + } + + Some(String::from_utf16_lossy(&wchars[..len])) +} diff --git a/crates/openshell-driver-mxc/src/lib.rs b/crates/openshell-driver-mxc/src/lib.rs index dc251649f8..4b9d328e9f 100644 --- a/crates/openshell-driver-mxc/src/lib.rs +++ b/crates/openshell-driver-mxc/src/lib.rs @@ -27,6 +27,11 @@ mod policy; // crate). Windows-only — MXC and the policy mapper are not built for Linux/WSL. #[cfg(target_os = "windows")] mod policy_map; +// Real-time ETW → OCSF audit consumer (Plane A). Consumes the OS Sandboxing +// provider MXC drives and emits OCSF through the gateway's tracing sink. +// Windows-only. +#[cfg(target_os = "windows")] +mod etw_consumer; #[cfg(target_os = "windows")] pub use driver::{MxcBackend, MxcComputeBackend, MxcComputeConfig}; diff --git a/crates/openshell-ocsf/src/builders/mod.rs b/crates/openshell-ocsf/src/builders/mod.rs index e63b2be88f..415ade2f7d 100644 --- a/crates/openshell-ocsf/src/builders/mod.rs +++ b/crates/openshell-ocsf/src/builders/mod.rs @@ -222,10 +222,11 @@ impl SandboxContext { } } - /// Build the OCSF `Device` object. + /// Build the OCSF `Device` object, stamped with the host OS this build runs + /// on (Linux for the in-sandbox supervisor, Windows for the MXC gateway). #[must_use] pub fn device(&self) -> Device { - Device::linux(&self.hostname) + Device::for_current_os(&self.hostname) } /// Build the `proxy_endpoint` object for the Network Proxy profile. diff --git a/crates/openshell-ocsf/src/lib.rs b/crates/openshell-ocsf/src/lib.rs index 345ea57175..2101beffee 100644 --- a/crates/openshell-ocsf/src/lib.rs +++ b/crates/openshell-ocsf/src/lib.rs @@ -64,5 +64,6 @@ pub use builders::{ // --- Tracing layers --- pub use tracing_layers::{ - OCSF_TARGET, OcsfJsonlLayer, OcsfShorthandLayer, clone_current_event, emit_ocsf_event, + OCSF_TARGET, OcsfJsonlLayer, OcsfShorthandLayer, clear_current_event, clone_current_event, + emit_ocsf_event, emit_ocsf_event_routed, set_current_event, }; diff --git a/crates/openshell-ocsf/src/objects/device.rs b/crates/openshell-ocsf/src/objects/device.rs index 4c42fb4a1f..bb8b3b57dc 100644 --- a/crates/openshell-ocsf/src/objects/device.rs +++ b/crates/openshell-ocsf/src/objects/device.rs @@ -34,6 +34,34 @@ impl Device { }), } } + + /// Create a Windows device with the given hostname. + #[must_use] + pub fn windows(hostname: &str) -> Self { + Self { + hostname: hostname.to_string(), + os: Some(OsInfo { + name: "Windows".to_string(), + }), + } + } + + /// Create a device stamped with the OS this build is running on. + /// + /// The gateway (Windows) and the Linux supervisor emit through the same + /// builders; the `device.os.name` should reflect the host each runs on — + /// an OS-appropriate difference, not a divergence. + #[must_use] + pub fn for_current_os(hostname: &str) -> Self { + #[cfg(target_os = "windows")] + { + Self::windows(hostname) + } + #[cfg(not(target_os = "windows"))] + { + Self::linux(hostname) + } + } } #[cfg(test)] diff --git a/crates/openshell-ocsf/src/tracing_layers/event_bridge.rs b/crates/openshell-ocsf/src/tracing_layers/event_bridge.rs index c07cd64b53..58f5f554b0 100644 --- a/crates/openshell-ocsf/src/tracing_layers/event_bridge.rs +++ b/crates/openshell-ocsf/src/tracing_layers/event_bridge.rs @@ -36,20 +36,54 @@ pub fn clone_current_event() -> Option { /// Both layers receive the event — `clone_current_event()` is non-consuming. pub fn emit_ocsf_event(event: OcsfEvent) { // Store the event in thread-local so layers can access it - CURRENT_EVENT.with(|cell| { - *cell.borrow_mut() = Some(event); - }); + set_current_event(event); // Emit a tracing event with the `ocsf` target. // The layers detect this target and clone the OcsfEvent from thread-local. tracing::info!(target: "ocsf", "ocsf_event"); // Clear the thread-local after dispatch completes. + clear_current_event(); +} + +/// Store an `OcsfEvent` in the thread-local bridge so OCSF layers +/// (`OcsfJsonlLayer` / `OcsfShorthandLayer`) can `clone_current_event()` it +/// during tracing dispatch. Pair with [`clear_current_event`] after the emit. +/// +/// Exposed so callers that need to attach extra tracing fields to the *same* +/// event (e.g. the gateway's per-sandbox `sandbox_id` routing field — see +/// [`emit_ocsf_event_routed`]) can drive the bridge directly. +pub fn set_current_event(event: OcsfEvent) { + CURRENT_EVENT.with(|cell| { + *cell.borrow_mut() = Some(event); + }); +} + +/// Clear the thread-local bridge slot. Call after the `ocsf`-target tracing +/// event has been dispatched so it does not leak into the next emit. +pub fn clear_current_event() { CURRENT_EVENT.with(|cell| { cell.borrow_mut().take(); }); } +/// Emit an `OcsfEvent` that is BOTH picked up by the structured OCSF layers +/// (via the thread-local bridge → `OcsfJsonlLayer` writes full JSON) AND +/// routed by the gateway's `TracingLogBus` (via the `sandbox_id` + `message` +/// tracing fields → per-sandbox stream / stdout shorthand). +/// +/// This is the gateway/multi-sandbox counterpart of [`emit_ocsf_event`]: the +/// Linux in-sandbox supervisor uses the process-wide `ctx()` singleton and the +/// bare `emit_ocsf_event`, but the gateway hosts many sandboxes, so it stamps a +/// per-event `sandbox_id` field here instead. One tracing event feeds both the +/// JSONL audit file and the routing bus. +pub fn emit_ocsf_event_routed(sandbox_id: &str, event: OcsfEvent) { + let message = event.format_shorthand(); + set_current_event(event); + tracing::info!(target: "ocsf", sandbox_id = %sandbox_id, message = %message); + clear_current_event(); +} + /// Convenience macro for emitting an `OcsfEvent`. /// /// ```ignore @@ -129,4 +163,71 @@ mod tests { // Should be empty now assert!(clone_current_event().is_none()); } + + /// A `Write` sink that appends into a shared buffer we can inspect. + #[derive(Clone)] + struct SharedWriter(std::sync::Arc>>); + + impl std::io::Write for SharedWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + // cp6: the gateway routed emit must (a) drive the JSONL layer with the FULL + // structured event (parity with the Linux `ocsf_emit!` path) and (b) leave + // no residue in the thread-local afterward. + #[test] + fn test_routed_emit_writes_full_json_and_clears() { + use crate::tracing_layers::OcsfJsonlLayer; + use tracing_subscriber::prelude::*; + + let buf = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let layer = OcsfJsonlLayer::new(SharedWriter(buf.clone())); + let subscriber = tracing_subscriber::registry().with(layer); + + tracing::subscriber::with_default(subscriber, || { + emit_ocsf_event_routed("sb-parity-1", test_event()); + }); + + let out = String::from_utf8(buf.lock().unwrap().clone()).unwrap(); + // Exactly one JSONL line, and it is valid full OCSF JSON (not shorthand). + assert_eq!(out.matches('\n').count(), 1, "one JSONL line expected"); + let parsed: serde_json::Value = serde_json::from_str(out.trim()).unwrap(); + assert_eq!(parsed["class_uid"], 0); + assert!(parsed.get("metadata").is_some()); + + // Thread-local must be clear after the routed emit (no bleed). + assert!(clone_current_event().is_none()); + } + + // cp6 parity: the routed path and the bare Linux path serialize the SAME + // structured event identically — routing fields don't alter the JSON body. + #[test] + fn test_routed_and_bare_paths_emit_equivalent_json() { + use crate::tracing_layers::OcsfJsonlLayer; + use tracing_subscriber::prelude::*; + + fn capture(f: impl FnOnce()) -> String { + let buf = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let layer = OcsfJsonlLayer::new(SharedWriter(buf.clone())); + let subscriber = tracing_subscriber::registry().with(layer); + tracing::subscriber::with_default(subscriber, f); + String::from_utf8(buf.lock().unwrap().clone()).unwrap() + } + + let bare = capture(|| emit_ocsf_event(test_event())); + let routed = capture(|| emit_ocsf_event_routed("sb-1", test_event())); + + let bare_json: serde_json::Value = serde_json::from_str(bare.trim()).unwrap(); + let routed_json: serde_json::Value = serde_json::from_str(routed.trim()).unwrap(); + assert_eq!( + bare_json, routed_json, + "routed emit must match the bare path JSON" + ); + } } diff --git a/crates/openshell-ocsf/src/tracing_layers/mod.rs b/crates/openshell-ocsf/src/tracing_layers/mod.rs index c8e5d9f2e4..b57ba1364a 100644 --- a/crates/openshell-ocsf/src/tracing_layers/mod.rs +++ b/crates/openshell-ocsf/src/tracing_layers/mod.rs @@ -11,6 +11,9 @@ pub(crate) mod event_bridge; mod jsonl_layer; mod shorthand_layer; -pub use event_bridge::{OCSF_TARGET, clone_current_event, emit_ocsf_event}; +pub use event_bridge::{ + OCSF_TARGET, clear_current_event, clone_current_event, emit_ocsf_event, emit_ocsf_event_routed, + set_current_event, +}; pub use jsonl_layer::OcsfJsonlLayer; pub use shorthand_layer::OcsfShorthandLayer; diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index ae35fc0fbf..75e433697a 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -72,6 +72,7 @@ anyhow = { workspace = true } # Logging tracing = { workspace = true } tracing-subscriber = { workspace = true } +tracing-appender = { workspace = true } # OpenTelemetry (OTLP trace export, opt-in via [openshell.gateway.otlp]) opentelemetry = { workspace = true } diff --git a/crates/openshell-server/src/tracing_setup.rs b/crates/openshell-server/src/tracing_setup.rs index edcf303072..4206bba856 100644 --- a/crates/openshell-server/src/tracing_setup.rs +++ b/crates/openshell-server/src/tracing_setup.rs @@ -7,6 +7,7 @@ //! `OpenShell` product telemetry collected for maintainers is handled by //! [`crate::telemetry`]. +use openshell_ocsf::OcsfJsonlLayer; use opentelemetry_sdk::trace::SdkTracerProvider; use tracing_subscriber::EnvFilter; use tracing_subscriber::prelude::*; @@ -168,11 +169,13 @@ pub fn install( .map(|config| config.endpoint.as_str()); let (driver_tracer_provider, driver_setup_error) = in_process_driver_provider(selected_driver, driver_endpoint, gateway.name()); + let (jsonl_layer, jsonl_dir) = build_ocsf_jsonl_layer(); tracing_subscriber::registry() .with(env_filter) .with(tracing_subscriber::fmt::layer()) .with(tracing_log_bus.layer()) + .with(jsonl_layer) .with(tracer_provider.as_ref().map(|provider| { crate::otel_tracing::layer_excluding_driver( provider, @@ -185,6 +188,18 @@ pub fn install( )) .init(); + match jsonl_dir { + Some(dir) => tracing::info!( + target: "openshell_server", + ocsf_jsonl_dir = %dir.display(), + "OCSF JSONL audit log enabled (openshell-ocsf..log, daily rotation, keep 3)" + ), + None => tracing::debug!( + target: "openshell_server", + "OCSF JSONL audit log disabled" + ), + } + ( TracingHandle { tracer_provider, @@ -194,6 +209,84 @@ pub fn install( ) } +/// Build the OCSF JSONL audit layer for the gateway, plus the directory it +/// writes into (for a one-line startup log). Returns `(None, None)` when +/// disabled via `OPENSHELL_OCSF_JSON` or when the target directory/appender +/// cannot be opened. +/// +/// The appender is *synchronous* (not wrapped in `tracing_appender::non_blocking`) +/// so each event is written straight through to the OS on emit. This trades a +/// little throughput for durability: unlike the sandbox supervisor (which flushes +/// its non-blocking guard on graceful shutdown), the gateway's ETW capture path +/// can be force-killed by the harness, and we do not want to lose the tail of the +/// audit trail. +fn build_ocsf_jsonl_layer() -> ( + Option>, + Option, +) { + let disabled = std::env::var("OPENSHELL_OCSF_JSON") + .map(|v| { + matches!( + v.trim().to_ascii_lowercase().as_str(), + "0" | "false" | "off" | "no" + ) + }) + .unwrap_or(false); + if disabled { + return (None, None); + } + + let dir = ocsf_log_dir(); + if let Err(e) = std::fs::create_dir_all(&dir) { + eprintln!( + "openshell: could not create OCSF JSONL log dir {}: {e}", + dir.display() + ); + return (None, None); + } + + match tracing_appender::rolling::RollingFileAppender::builder() + .rotation(tracing_appender::rolling::Rotation::DAILY) + .filename_prefix("openshell-ocsf") + .filename_suffix("log") + .max_log_files(3) + .build(&dir) + { + Ok(roller) => (Some(OcsfJsonlLayer::new(roller)), Some(dir)), + Err(e) => { + eprintln!( + "openshell: could not open OCSF JSONL appender in {}: {e}", + dir.display() + ); + (None, None) + } + } +} + +/// Resolve the directory for the OCSF JSONL audit file. +/// +/// Precedence: `OPENSHELL_OCSF_LOG_DIR` (harness / operator override) → +/// `%PROGRAMDATA%\OpenShell\logs` on Windows → `/var/log` elsewhere. +fn ocsf_log_dir() -> std::path::PathBuf { + if let Ok(dir) = std::env::var("OPENSHELL_OCSF_LOG_DIR") { + let trimmed = dir.trim(); + if !trimmed.is_empty() { + return std::path::PathBuf::from(trimmed); + } + } + #[cfg(target_os = "windows")] + { + if let Ok(pd) = std::env::var("ProgramData") { + return std::path::PathBuf::from(pd).join("OpenShell").join("logs"); + } + std::env::temp_dir().join("openshell").join("logs") + } + #[cfg(not(target_os = "windows"))] + { + std::path::PathBuf::from("/var/log") + } +} + #[cfg(test)] mod tests { use super::*; From 75f667a07515a0d52984ad4e27b1697f1cf0e0b2 Mon Sep 17 00:00:00 2001 From: Jamie King Date: Thu, 9 Jul 2026 13:47:25 -0600 Subject: [PATCH 02/31] feat(mxc): map remaining Sandboxing ETW events to OCSF Close the last three ETW->OCSF gaps so the audit trail covers the full set of events the Sandboxing provider emits (12/12): - ProcessLaunched -> Process Activity [1007] "Launch" (confirmed start; carries the real processId/threadId, the twin of CreateProcessInSandbox which only has the request + command line). - SandboxProxyConfigured -> Device Config State Change [5019] (the one network-plane setup event; surfaces proxyPort, "no proxy" when 0). - SandboxConsoleReferencePlumbed -> Device Config State Change [5019] (console-handle plumbing). map_config_state now handles the full config/hardening/setup family and carries proxyPort/hasConsoleReference/creationFlags as unmapped fields. Verified on 7F203-MXC-001: 11/12 event types emit OCSF without a proxy (SandboxProxyConfigured requires proxy config to fire). Signed-off-by: Akber Raza --- .../openshell-driver-mxc/src/etw_consumer.rs | 85 ++++++++++++++++--- 1 file changed, 71 insertions(+), 14 deletions(-) diff --git a/crates/openshell-driver-mxc/src/etw_consumer.rs b/crates/openshell-driver-mxc/src/etw_consumer.rs index 6db6009a72..55a0897e09 100644 --- a/crates/openshell-driver-mxc/src/etw_consumer.rs +++ b/crates/openshell-driver-mxc/src/etw_consumer.rs @@ -974,12 +974,24 @@ fn process_event(index: &Mutex, ev: DecodedEtwEvent) { tracing::debug!(target: "mxc_etw", pid = ev.process_id, sandbox_id = %sandbox_id, "{}", ev.summary()); } } - // Config [5019]: several distinct config/hardening state changes. Each is - // a genuine audit-worthy config event; `SandboxConfig` is the richest but + // Process [1007]: `ProcessLaunched` is the confirmation twin of + // `CreateProcessInSandbox` — it carries the *actual* `processId`/`threadId` + // of the started in-sandbox process (the create event only has the request + + // command line). We emit it as a distinct PROC row so the trail records both + // the launch request (with cmd line) and the confirmed start (with real pid). + "ProcessLaunched" => { + let ctx = etw_ctx(&sandbox_id, &sandbox_name); + emit_ocsf(&sandbox_id, map_process_started(&ctx, &ev)); + } + // Config [5019]: several distinct config/hardening/setup state changes. Each + // is a genuine audit-worthy config event; `SandboxConfig` is the richest but // drops intermittently, so the reliably-captured hardening events // (`Win32kLockdownApplied`, `ApplyUILimits`, `EnforceOsPolicy`) guarantee - // coverage. - "SandboxConfig" | "Win32kLockdownApplied" | "ApplyUILimits" | "EnforceOsPolicy" => { + // coverage. `SandboxProxyConfigured` (network/proxy setup — the one + // network-plane event the provider emits) and `SandboxConsoleReferencePlumbed` + // (console-handle plumbing) are additional per-sandbox setup state changes. + "SandboxConfig" | "Win32kLockdownApplied" | "ApplyUILimits" | "EnforceOsPolicy" + | "SandboxProxyConfigured" | "SandboxConsoleReferencePlumbed" => { let ctx = etw_ctx(&sandbox_id, &sandbox_name); emit_ocsf(&sandbox_id, map_config_state(&ctx, &ev)); } @@ -1012,12 +1024,15 @@ fn map_lifecycle_create(ctx: &SandboxContext, sandbox_name: &str) -> OcsfEvent { .build() } -/// A sandbox config/hardening ETW event → Device Config State Change [5019]. +/// A sandbox config/hardening/setup ETW event → Device Config State Change [5019]. /// -/// Handles both `SandboxConfig` (full posture snapshot) and -/// `Win32kLockdownApplied` (agentic win32k lockdown): whichever config-ish -/// fields the event carries ride along as `unmapped`, and `security_level` -/// reflects any hardening signal present. +/// Handles the full family of per-sandbox config state changes the Sandboxing +/// provider emits: `SandboxConfig` (full posture snapshot), the hardening events +/// (`Win32kLockdownApplied`, `ApplyUILimits`, `EnforceOsPolicy`), +/// `SandboxProxyConfigured` (network/proxy setup) and +/// `SandboxConsoleReferencePlumbed` (console-handle plumbing). Whichever +/// config-ish fields the event carries ride along as `unmapped`, and +/// `security_level` reflects any hardening signal present. fn map_config_state(ctx: &SandboxContext, ev: &DecodedEtwEvent) -> OcsfEvent { let flag = |k: &str| ev.get(k).map(|v| v == "1").unwrap_or(false); let nonzero = |k: &str| ev.get(k).map(|v| v != "0").unwrap_or(false); @@ -1029,10 +1044,17 @@ fn map_config_state(ctx: &SandboxContext, ev: &DecodedEtwEvent) -> OcsfEvent { }; let message = match ev.event_name.as_deref().unwrap_or("") { - "Win32kLockdownApplied" => "MXC sandbox win32k lockdown applied", - "ApplyUILimits" => "MXC sandbox UI restrictions applied", - "EnforceOsPolicy" => "MXC sandbox OS policy enforced", - _ => "MXC sandbox OS policy configured", + "Win32kLockdownApplied" => "MXC sandbox win32k lockdown applied".to_string(), + "ApplyUILimits" => "MXC sandbox UI restrictions applied".to_string(), + "EnforceOsPolicy" => "MXC sandbox OS policy enforced".to_string(), + "SandboxConsoleReferencePlumbed" => "MXC sandbox console reference plumbed".to_string(), + // The one network-plane event the provider emits; `proxyPort=0` means no + // proxy was configured. Surface the port so the CONFIG row is self-describing. + "SandboxProxyConfigured" => match ev.get_unquoted("proxyPort").as_deref() { + Some("0") | None => "MXC sandbox proxy configured (no proxy)".to_string(), + Some(port) => format!("MXC sandbox proxy configured (port {port})"), + }, + _ => "MXC sandbox OS policy configured".to_string(), }; let mut builder = ConfigStateChangeBuilder::new(ctx) @@ -1042,7 +1064,7 @@ fn map_config_state(ctx: &SandboxContext, ev: &DecodedEtwEvent) -> OcsfEvent { .status(StatusId::Success) .message(message); - // Superset of config-ish fields across both event shapes; only present + // Superset of config-ish fields across all event shapes; only present // fields are attached. for key in [ "useAppContainer", @@ -1055,6 +1077,9 @@ fn map_config_state(ctx: &SandboxContext, ev: &DecodedEtwEvent) -> OcsfEvent { "capabilities", "agenticFlags", "processId", + "proxyPort", + "hasConsoleReference", + "creationFlags", ] { if let Some(v) = ev.get(key) { builder = builder.unmapped(key, v.trim_matches('"').to_string()); @@ -1091,6 +1116,38 @@ fn map_process_launch(ctx: &SandboxContext, ev: &DecodedEtwEvent, cmd_line: &str .build() } +/// `ProcessLaunched` → Process Activity [1007] "Launch" (confirmed start). +/// +/// Unlike `CreateProcessInSandbox` (the request, which carries the command line +/// but not the resulting pid), this event carries the real `processId`/`threadId` +/// of the process that actually started. We give the process a distinct name +/// (`sandboxed-process`) so the shorthand row is visibly the confirmed-start twin, +/// not a duplicate of the launch-request row. +fn map_process_started(ctx: &SandboxContext, ev: &DecodedEtwEvent) -> OcsfEvent { + let pid = ev + .get("processId") + .map(|v| v.trim_matches('"')) + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + let tid = ev.get_unquoted("threadId").unwrap_or_default(); + let tid_suffix = if tid.is_empty() { + String::new() + } else { + format!(", tid: {tid}") + }; + ProcessActivityBuilder::new(ctx) + .activity(ActivityId::Open) // process label = "Launch" + .launch_type(LaunchTypeId::Spawn) + .action(ActionId::Allowed) + .disposition(DispositionId::Allowed) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .process(Process::new("sandboxed-process", pid)) + .actor_process(Process::new("wxc-exec", i64::from(ev.process_id))) + .message(format!("MXC sandbox process started (pid: {pid}{tid_suffix})")) + .build() +} + /// `ActivityError` / `FallbackError` → Detection Finding [2004] (informational). fn map_finding(ctx: &SandboxContext, ev: &DecodedEtwEvent) -> OcsfEvent { let kind = ev.event_name.as_deref().unwrap_or("SandboxError"); From 8b9c42366f4b966017ba78bfa099dcba2341db78 Mon Sep 17 00:00:00 2001 From: Jamie King Date: Thu, 9 Jul 2026 13:59:25 -0600 Subject: [PATCH 03/31] fix(mxc): seed ETW attribution under registry lock + Device tests Address CodeRabbit review on !31: - Prevent stale ETW attribution on a delete/launch race: register the wxc-exec pid while holding the registry lock, and bail if the sandbox entry is already gone. Previously the attribution key could be seeded after `delete` had removed the sandbox, leaving a stale key that could misroute later Sandboxing ETW events to a dead sandbox_id. Lock order (registry -> attribution) matches the delete path, so no deadlock. - Add unit tests for the new Device::windows and Device::for_current_os constructors to harden Windows/Linux OCSF device parity. Signed-off-by: Akber Raza --- crates/openshell-driver-mxc/src/driver.rs | 52 +++++++++++-------- .../openshell-driver-mxc/src/etw_consumer.rs | 12 +++-- crates/openshell-ocsf/src/objects/device.rs | 19 +++++++ 3 files changed, 58 insertions(+), 25 deletions(-) diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 97234ba792..b5334fda70 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -766,16 +766,6 @@ async fn run_lifecycle( }; info!(sandbox = %sandbox_name, command = %command_line, backend = ?config.backend, "MXC agent launched"); - // Seed ETW attribution: the wxc-exec pid we just spawned is the - // collision-proof anchor that ties the `Sandboxing` provider's events back - // to this `sandbox_id` (command line is a fallback matcher). No-op unless - // the ETW consumer is running. - if let Some(pid) = child.id() { - if let Ok(mut idx) = attribution.lock() { - idx.register_launch(&sandbox_id, &sandbox_name, pid, &command_line); - } - } - let ready_sandbox = make_sandbox_with_condition( &sandbox, &DriverCondition { @@ -793,19 +783,37 @@ async fn run_lifecycle( // 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.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 Some(entry) = registry_guard.get_mut(&sandbox_id) else { + // The sandbox was deleted between agent launch and readiness. Bail + // without seeding ETW attribution (a stale key would misroute later + // events to a dead sandbox), without reporting Ready, and without + // spawning the exec monitor. `delete` already tore down the process. + return; + }; + + // Seed ETW attribution while holding the registry lock so a concurrent + // `delete` cannot remove the sandbox after we register (which would leave + // a stale key). The `wxc-exec` pid we just spawned is the collision-proof + // anchor that ties the `Sandboxing` provider's events back to this + // `sandbox_id` (command line is a fallback matcher). No-op unless the ETW + // consumer is running. + if let Some(pid) = child.id() { + if let Ok(mut idx) = attribution.lock() { + idx.register_launch(&sandbox_id, &sandbox_name, pid, &command_line); + } } + + entry.sandbox = ready_sandbox.clone(); + entry.phase_state = PhaseState::Running; + 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)); } diff --git a/crates/openshell-driver-mxc/src/etw_consumer.rs b/crates/openshell-driver-mxc/src/etw_consumer.rs index 55a0897e09..68a20d416a 100644 --- a/crates/openshell-driver-mxc/src/etw_consumer.rs +++ b/crates/openshell-driver-mxc/src/etw_consumer.rs @@ -990,8 +990,12 @@ fn process_event(index: &Mutex, ev: DecodedEtwEvent) { // coverage. `SandboxProxyConfigured` (network/proxy setup — the one // network-plane event the provider emits) and `SandboxConsoleReferencePlumbed` // (console-handle plumbing) are additional per-sandbox setup state changes. - "SandboxConfig" | "Win32kLockdownApplied" | "ApplyUILimits" | "EnforceOsPolicy" - | "SandboxProxyConfigured" | "SandboxConsoleReferencePlumbed" => { + "SandboxConfig" + | "Win32kLockdownApplied" + | "ApplyUILimits" + | "EnforceOsPolicy" + | "SandboxProxyConfigured" + | "SandboxConsoleReferencePlumbed" => { let ctx = etw_ctx(&sandbox_id, &sandbox_name); emit_ocsf(&sandbox_id, map_config_state(&ctx, &ev)); } @@ -1144,7 +1148,9 @@ fn map_process_started(ctx: &SandboxContext, ev: &DecodedEtwEvent) -> OcsfEvent .status(StatusId::Success) .process(Process::new("sandboxed-process", pid)) .actor_process(Process::new("wxc-exec", i64::from(ev.process_id))) - .message(format!("MXC sandbox process started (pid: {pid}{tid_suffix})")) + .message(format!( + "MXC sandbox process started (pid: {pid}{tid_suffix})" + )) .build() } diff --git a/crates/openshell-ocsf/src/objects/device.rs b/crates/openshell-ocsf/src/objects/device.rs index bb8b3b57dc..0f38ef446e 100644 --- a/crates/openshell-ocsf/src/objects/device.rs +++ b/crates/openshell-ocsf/src/objects/device.rs @@ -75,4 +75,23 @@ mod tests { assert_eq!(json["hostname"], "sandbox-abc123"); assert_eq!(json["os"]["name"], "Linux"); } + + #[test] + fn test_device_windows() { + let device = Device::windows("gateway-host"); + let json = serde_json::to_value(&device).unwrap(); + assert_eq!(json["hostname"], "gateway-host"); + assert_eq!(json["os"]["name"], "Windows"); + } + + #[test] + fn test_device_for_current_os() { + let device = Device::for_current_os("host"); + let json = serde_json::to_value(&device).unwrap(); + assert_eq!(json["hostname"], "host"); + #[cfg(target_os = "windows")] + assert_eq!(json["os"]["name"], "Windows"); + #[cfg(not(target_os = "windows"))] + assert_eq!(json["os"]["name"], "Linux"); + } } From 9467b464e1bcdc6b2b4f12ee9ed4e028a76d8b25 Mon Sep 17 00:00:00 2001 From: Jamie King Date: Tue, 14 Jul 2026 14:04:14 -0600 Subject: [PATCH 04/31] fix(mxc-etw): buffer+replay racing events and harden attribution keys Addresses two ETW->OCSF attribution review items (Shailendra #1, #2). #2 early-event loss: ETW delivers the sandbox create/config burst the instant wxc-exec starts, which can beat the driver's register_launch (now under the registry lock post-Ready). process_event previously dropped anything unresolved, losing the racing burst. Add a bounded, time-bounded pending buffer (PENDING_MAX=4096, PENDING_TTL=5s): unresolved events are held and replayed once attribution lands, aged-out ones dropped. Consumer switched to a timed recv_timeout(200ms) so the buffer is re-driven after each event and on a tick. Emit path factored into shared emit_resolved(). #1 attribution collisions: a Windows PID is recycled after exit and a command line is commonly identical across sandboxes. register_launch now rebinds by_pid on reuse and clears the stale last_pid_sid hint (warns if the PID still pointed at a different, leaked sandbox); command line is held in by_cmd only while unique and demoted to a new ambiguous_cmds set on a second owner, so a duplicate command refuses to resolve rather than misroute. Unit tests: buffer replay (direct + cross-link), buffer bound, PID-reuse rebind, duplicate-cmd non-resolution. Box-verified on 7F203-MXC-001 (5 sandboxes, identical cmd -> 5 isolated sandbox_ids, 50/50 OCSF/JSONL, BuffersLost=0). Signed-off-by: Akber Raza --- .../openshell-driver-mxc/src/etw_consumer.rs | 352 ++++++++++++++++-- 1 file changed, 318 insertions(+), 34 deletions(-) diff --git a/crates/openshell-driver-mxc/src/etw_consumer.rs b/crates/openshell-driver-mxc/src/etw_consumer.rs index 68a20d416a..856a79a168 100644 --- a/crates/openshell-driver-mxc/src/etw_consumer.rs +++ b/crates/openshell-driver-mxc/src/etw_consumer.rs @@ -50,11 +50,12 @@ clippy::doc_markdown )] -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::ffi::c_void; use std::sync::mpsc; use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; +use std::time::{Duration, Instant}; use windows::Win32::Foundation::WIN32_ERROR; use windows::Win32::System::Diagnostics::Etw::{ @@ -229,7 +230,8 @@ impl EtwSession { self.handle = 0; } // ControlTraceW(STOP) makes ProcessTrace return → the pump thread ends and - // drops the boxed Sender → the consumer thread's `for ev in rx` ends. + // drops the boxed Sender → the consumer thread's recv loop sees + // `Disconnected`, does a final pending drain, and exits. if let Some(t) = self.pump_thread.take() { let _ = t.join(); } @@ -273,18 +275,32 @@ pub(crate) fn start_session(index: Arc>) -> Result process_event(&index, ev), - None => tracing::debug!( - target: "mxc_etw", - id = raw.header.EventDescriptor.Id, - opcode = raw.header.EventDescriptor.Opcode, - pid = raw.header.ProcessId, - "TDH decode failed for event" - ), + // + // A *timed* recv lets us also re-drive the pending buffer during a + // lull: an event that beat the driver's `register_launch` is replayed + // within one tick once attribution lands, without having to wait for + // the next ETW event (which may never arrive for a lone/last sandbox). + loop { + match rx.recv_timeout(Duration::from_millis(200)) { + Ok(mut raw) => { + match decode_raw(&mut raw) { + Some(ev) => process_event(&index, ev), + None => tracing::debug!( + target: "mxc_etw", + id = raw.header.EventDescriptor.Id, + opcode = raw.header.EventDescriptor.Opcode, + pid = raw.header.ProcessId, + "TDH decode failed for event" + ), + } + drain_and_emit(&index); + } + Err(mpsc::RecvTimeoutError::Timeout) => drain_and_emit(&index), + Err(mpsc::RecvTimeoutError::Disconnected) => break, } } + // Final drain on shutdown so anything still resolvable is emitted. + drain_and_emit(&index); }) .map_err(|e| { stop_session(handle); @@ -812,18 +828,49 @@ fn format_property_value( /// `activity_id → sandbox_id`, so the payload-keyless `SandboxConfig` /// (no identity/CV) resolves via the ETW `ActivityId` it shares. /// - `commandLine` and a per-pid "last resolved" value are fallbacks. +/// An ETW event that could not yet be attributed, held so it can be replayed +/// once its sandbox's attribution is seeded. +struct PendingEvent { + at: Instant, + ev: DecodedEtwEvent, +} + +/// Max number of unattributed events buffered at once (memory bound). The +/// create/config burst is ~10 events per sandbox, so this comfortably holds +/// many concurrent racing launches while still capping worst-case memory. +const PENDING_MAX: usize = 4096; + +/// How long an unattributed event is held before being given up on. The +/// driver seeds attribution within milliseconds of spawning `wxc-exec`, so a +/// few seconds is ample; anything older is almost certainly genuinely +/// unattributable (e.g. an unrelated Sandboxing-provider consumer on the box). +const PENDING_TTL: Duration = Duration::from_secs(5); + #[derive(Default)] pub(crate) struct AttributionIndex { by_pid: HashMap, by_identity: HashMap, by_activity: HashMap, by_cv: HashMap, + /// Command line → sandbox_id, but **only while that command line is unique**. + /// The instant a second sandbox registers the same command line it is moved to + /// [`Self::ambiguous_cmds`] and removed here, so an ambiguous command can never + /// misroute an event. Command line is a weak, last-resort key for exactly this + /// reason (two sandboxes commonly run the identical agent command). by_cmd: HashMap, + /// Command lines seen for more than one sandbox — never usable for resolution. + ambiguous_cmds: std::collections::HashSet, last_pid_sid: HashMap, names: HashMap, /// Sandboxes for which a lifecycle [6002] row has already been emitted, so /// the two redundant create events don't double-count. lifecycle_emitted: std::collections::HashSet, + /// Events that arrived before their sandbox's attribution was seeded. ETW + /// delivers the create/config burst the instant `wxc-exec` starts, which can + /// race the driver's `register_launch`; rather than drop those events we hold + /// them here and replay when a later registration/cross-link resolves them. + /// Bounded by [`PENDING_MAX`] and [`PENDING_TTL`]. + pending: VecDeque, } impl AttributionIndex { @@ -832,7 +879,9 @@ impl AttributionIndex { } /// Register a launched sandbox. `wxc_pid` (the process we spawned) is the - /// collision-proof anchor; `command_line` is a fallback matcher. + /// primary anchor — unique *while that process is alive* (Windows won't reuse + /// a live PID). `command_line` is only a weak fallback and is dropped the + /// moment it stops being unique (see [`Self::ambiguous_cmds`]). pub fn register_launch( &mut self, sandbox_id: &str, @@ -840,11 +889,40 @@ impl AttributionIndex { wxc_pid: u32, command_line: &str, ) { + // PID-reuse guard: if this PID still maps to a *different* sandbox, the + // prior sandbox was never `forget()`-ten (e.g. a crash skipped `delete`) + // and Windows has recycled the number. Rebind to the new owner and drop + // the stale per-PID "last resolved" hint so it can't misroute. + if let Some(prev) = self.by_pid.get(&wxc_pid) { + if prev != sandbox_id { + tracing::warn!( + target: "mxc_etw", + pid = wxc_pid, + prev = %prev, + new = %sandbox_id, + "wxc-exec PID reused before prior sandbox was forgotten; rebinding attribution" + ); + } + } self.by_pid.insert(wxc_pid, sandbox_id.to_string()); - if !command_line.is_empty() { - self.by_cmd - .insert(command_line.to_string(), sandbox_id.to_string()); + self.last_pid_sid.remove(&wxc_pid); + + // Command line is only trustworthy while unique. Promote to `by_cmd` on + // first sight; on a second, different owner, demote to ambiguous forever. + if !command_line.is_empty() && !self.ambiguous_cmds.contains(command_line) { + match self.by_cmd.get(command_line) { + Some(existing) if existing != sandbox_id => { + self.by_cmd.remove(command_line); + self.ambiguous_cmds.insert(command_line.to_string()); + } + Some(_) => {} // same owner re-registering; keep + None => { + self.by_cmd + .insert(command_line.to_string(), sandbox_id.to_string()); + } + } } + self.names .insert(sandbox_id.to_string(), sandbox_name.to_string()); } @@ -915,6 +993,57 @@ impl AttributionIndex { Some(sid) } + + /// Hold an event that didn't resolve yet, evicting expired and (if needed) + /// oldest entries first so the buffer stays bounded. + fn buffer_unresolved(&mut self, ev: DecodedEtwEvent) { + let now = Instant::now(); + while let Some(front) = self.pending.front() { + if now.duration_since(front.at) > PENDING_TTL { + let stale = self.pending.pop_front(); + if let Some(p) = stale { + tracing::debug!(target: "mxc_etw", pid = p.ev.process_id, "dropping unattributed (aged out) {}", p.ev.summary()); + } + } else { + break; + } + } + if self.pending.len() >= PENDING_MAX { + if let Some(p) = self.pending.pop_front() { + tracing::debug!(target: "mxc_etw", pid = p.ev.process_id, "dropping unattributed (buffer full) {}", p.ev.summary()); + } + } + self.pending.push_back(PendingEvent { at: now, ev }); + } + + /// Re-resolve buffered events. Returns those that now attribute (removed + /// from the buffer, in arrival order, ready to emit) and drops any that have + /// aged past [`PENDING_TTL`] still unresolved. Callers emit the returned + /// events *after* releasing the index lock. + fn drain_resolved(&mut self) -> Vec<(String, String, DecodedEtwEvent)> { + if self.pending.is_empty() { + return Vec::new(); + } + let now = Instant::now(); + let drained = std::mem::take(&mut self.pending); + let mut ready = Vec::new(); + let mut keep = VecDeque::with_capacity(drained.len()); + for p in drained { + if now.duration_since(p.at) > PENDING_TTL { + tracing::debug!(target: "mxc_etw", pid = p.ev.process_id, "dropping unattributed (aged out) {}", p.ev.summary()); + continue; + } + match self.resolve(&p.ev) { + Some(sid) => { + let name = self.name_of(&sid); + ready.push((sid, name, p.ev)); + } + None => keep.push_back(p), + } + } + self.pending = keep; + ready + } } /// Consumer-thread entry point: attribute one decoded event and, for the mapped @@ -926,25 +1055,56 @@ fn process_event(index: &Mutex, ev: DecodedEtwEvent) { return; } - let (sandbox_id, sandbox_name) = { + let resolved = { let mut idx = index .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); match idx.resolve(&ev) { Some(sid) => { let name = idx.name_of(&sid); - (sid, name) + Some((sid, name, ev)) } None => { - drop(idx); - tracing::debug!(target: "mxc_etw", pid = ev.process_id, "unattributed {}", ev.summary()); - return; + // Not attributable yet: ETW delivers the create/config burst the + // instant `wxc-exec` starts, which can beat the driver's + // `register_launch`. Hold the event for replay instead of dropping + // it (see `drain_and_emit`). + idx.buffer_unresolved(ev); + None } } }; - // STOP twins are already filtered above, so activity events reaching here - // are STARTs. + if let Some((sandbox_id, sandbox_name, ev)) = resolved { + emit_resolved(index, &sandbox_id, &sandbox_name, &ev); + } +} + +/// Re-resolve and emit any buffered events that have since become attributable. +/// Called by the consumer thread after each incoming event and on a periodic +/// tick, so a create/config burst that raced `register_launch` still lands in +/// the trail (and aged-out unresolvable events are dropped, bounded). +fn drain_and_emit(index: &Mutex) { + let ready = { + let mut idx = index + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + idx.drain_resolved() + }; + for (sandbox_id, sandbox_name, ev) in ready { + emit_resolved(index, &sandbox_id, &sandbox_name, &ev); + } +} + +/// Map one attributed event to its OCSF class and emit it into the gateway trail. +fn emit_resolved( + index: &Mutex, + sandbox_id: &str, + sandbox_name: &str, + ev: &DecodedEtwEvent, +) { + // STOP twins are already filtered before buffering, so activity events + // reaching here are STARTs. match ev.event_name.as_deref().unwrap_or("") { // Lifecycle [6002]: MXC emits two create events per sandbox — // `SandboxEngineCreate` and `SandboxCreateWithPolicyEnforcement` — and @@ -957,10 +1117,10 @@ fn process_event(index: &Mutex, ev: DecodedEtwEvent) { let first = index .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) - .take_lifecycle_once(&sandbox_id); + .take_lifecycle_once(sandbox_id); if first { - let ctx = etw_ctx(&sandbox_id, &sandbox_name); - emit_ocsf(&sandbox_id, map_lifecycle_create(&ctx, &sandbox_name)); + let ctx = etw_ctx(sandbox_id, sandbox_name); + emit_ocsf(sandbox_id, map_lifecycle_create(&ctx, sandbox_name)); } } // Process [1007]: `CreateProcessInSandbox` carries the real agent command @@ -968,8 +1128,8 @@ fn process_event(index: &Mutex, ev: DecodedEtwEvent) { // once with the command — only emit for the populated one. "CreateProcessInSandbox" if ev.opcode == OPCODE_START => { if let Some(cmd) = ev.get_unquoted("commandLine") { - let ctx = etw_ctx(&sandbox_id, &sandbox_name); - emit_ocsf(&sandbox_id, map_process_launch(&ctx, &ev, &cmd)); + let ctx = etw_ctx(sandbox_id, sandbox_name); + emit_ocsf(sandbox_id, map_process_launch(&ctx, ev, &cmd)); } else { tracing::debug!(target: "mxc_etw", pid = ev.process_id, sandbox_id = %sandbox_id, "{}", ev.summary()); } @@ -980,8 +1140,8 @@ fn process_event(index: &Mutex, ev: DecodedEtwEvent) { // command line). We emit it as a distinct PROC row so the trail records both // the launch request (with cmd line) and the confirmed start (with real pid). "ProcessLaunched" => { - let ctx = etw_ctx(&sandbox_id, &sandbox_name); - emit_ocsf(&sandbox_id, map_process_started(&ctx, &ev)); + let ctx = etw_ctx(sandbox_id, sandbox_name); + emit_ocsf(sandbox_id, map_process_started(&ctx, ev)); } // Config [5019]: several distinct config/hardening/setup state changes. Each // is a genuine audit-worthy config event; `SandboxConfig` is the richest but @@ -996,15 +1156,15 @@ fn process_event(index: &Mutex, ev: DecodedEtwEvent) { | "EnforceOsPolicy" | "SandboxProxyConfigured" | "SandboxConsoleReferencePlumbed" => { - let ctx = etw_ctx(&sandbox_id, &sandbox_name); - emit_ocsf(&sandbox_id, map_config_state(&ctx, &ev)); + let ctx = etw_ctx(sandbox_id, sandbox_name); + emit_ocsf(sandbox_id, map_config_state(&ctx, ev)); } // Finding [2004]: MXC surfaces WIL error/fallback activities during // sandbox setup. Captured as informational (non-alert) findings so the // audit trail records setup anomalies without crying wolf. "ActivityError" | "FallbackError" => { - let ctx = etw_ctx(&sandbox_id, &sandbox_name); - emit_ocsf(&sandbox_id, map_finding(&ctx, &ev)); + let ctx = etw_ctx(sandbox_id, sandbox_name); + emit_ocsf(sandbox_id, map_finding(&ctx, ev)); } _ => { tracing::debug!(target: "mxc_etw", pid = ev.process_id, sandbox_id = %sandbox_id, "{}", ev.summary()); @@ -1296,3 +1456,127 @@ fn wide_str_at(buf: &[u8], offset: u32) -> Option { Some(String::from_utf16_lossy(&wchars[..len])) } + +#[cfg(test)] +mod tests { + use super::*; + + fn mk_event(pid: u32, name: &str) -> DecodedEtwEvent { + DecodedEtwEvent { + provider: GUID::from_u128(0), + event_id: 1, + level: 4, + opcode: OPCODE_START, + process_id: pid, + activity_id: GUID::from_u128(0), + event_name: Some(name.to_string()), + props: Vec::new(), + } + } + + // Shailendra #2: the create/config burst can reach the consumer before the + // driver's `register_launch` seeds attribution. An event that doesn't resolve + // must be held and replayed once attribution lands — not dropped. + #[test] + fn buffered_event_replays_after_registration() { + let mut idx = AttributionIndex::new(); + let ev = mk_event(1234, "SandboxConfig"); + + // Arrives before registration → unresolved → buffered, not dropped. + assert!(idx.resolve(&ev).is_none()); + idx.buffer_unresolved(ev); + assert!( + idx.drain_resolved().is_empty(), + "nothing to drain pre-registration" + ); + + // Driver seeds attribution for the wxc-exec pid we spawned. + idx.register_launch("sbx-1", "my-sandbox", 1234, "agent --run"); + + // The buffered event now attributes and is returned for emit, in order. + let ready = idx.drain_resolved(); + assert_eq!(ready.len(), 1); + assert_eq!(ready[0].0, "sbx-1"); + assert_eq!(ready[0].1, "my-sandbox"); + assert_eq!(ready[0].2.process_id, 1234); + + // And it's removed from the buffer (no double emit). + assert!(idx.drain_resolved().is_empty()); + } + + // Genuinely unattributable events (e.g. from unrelated Sandboxing activity) + // must never grow the buffer without bound. + #[test] + fn pending_buffer_is_bounded() { + let mut idx = AttributionIndex::new(); + for pid in 0..(PENDING_MAX as u32 + 50) { + idx.buffer_unresolved(mk_event(pid, "SandboxConfig")); + } + assert!( + idx.pending.len() <= PENDING_MAX, + "buffer exceeded PENDING_MAX" + ); + } + + // A buffered event that resolves via a cross-linked correlator (not just the + // pid) is also replayed: register one pid, then an event sharing only the + // activity id resolves after the first event cross-links it. + #[test] + fn buffered_event_replays_via_crosslink() { + let mut idx = AttributionIndex::new(); + idx.register_launch("sbx-9", "s9", 4321, "agent"); + + // First event carries the pid + an activity id → resolves and cross-links + // the activity id to sbx-9. + let mut anchor = mk_event(4321, "CreateProcessInSandbox"); + anchor.activity_id = GUID::from_u128(0xABCD); + assert_eq!(idx.resolve(&anchor).as_deref(), Some("sbx-9")); + + // A later payload-keyless event shares only the activity id (different + // pid) — it must now resolve via the cross-link. + let mut keyless = mk_event(0, "SandboxConfig"); + keyless.activity_id = GUID::from_u128(0xABCD); + assert_eq!(idx.resolve(&keyless).as_deref(), Some("sbx-9")); + } + + // Shailendra #1 (PID reuse): if a sandbox leaked (no `forget`) and Windows + // recycles its wxc-exec PID for a new sandbox, events on that PID must route + // to the *new* owner, never the dead one. + #[test] + fn pid_reuse_rebinds_to_new_sandbox() { + let mut idx = AttributionIndex::new(); + idx.register_launch("sbx-A", "A", 1000, "agent --a"); + let ev_a = mk_event(1000, "CreateProcessInSandbox"); + assert_eq!(idx.resolve(&ev_a).as_deref(), Some("sbx-A")); + + // A leaks (delete never ran). PID 1000 is recycled for B. + idx.register_launch("sbx-B", "B", 1000, "agent --b"); + let ev_b = mk_event(1000, "CreateProcessInSandbox"); + assert_eq!(idx.resolve(&ev_b).as_deref(), Some("sbx-B")); + } + + // Shailendra #1 (cmd ambiguity): two sandboxes running the identical command + // line must not let that command line resolve anything (it's ambiguous); a + // unique command line still works as a fallback. + #[test] + fn duplicate_command_line_is_not_used_for_resolution() { + let mut idx = AttributionIndex::new(); + idx.register_launch("sbx-1", "s1", 11, "agent --run"); + idx.register_launch("sbx-2", "s2", 22, "agent --run"); // same cmd → ambiguous + + // Event carrying ONLY the duplicate command line (unknown pid, no + // identity/activity) must NOT resolve — refusing beats misrouting. + let mut only_cmd = mk_event(999, "SandboxConfig"); + only_cmd + .props + .push(("commandLine".into(), "\"agent --run\"".into())); + assert!(idx.resolve(&only_cmd).is_none()); + + // A still-unique command line resolves via the fallback as before. + idx.register_launch("sbx-3", "s3", 33, "agent --unique"); + let mut uniq = mk_event(998, "SandboxConfig"); + uniq.props + .push(("commandLine".into(), "\"agent --unique\"".into())); + assert_eq!(idx.resolve(&uniq).as_deref(), Some("sbx-3")); + } +} From 5efe3cdc339dfc89ca560da2348e538a56cdbf86 Mon Sep 17 00:00:00 2001 From: Jamie King Date: Tue, 14 Jul 2026 14:14:53 -0600 Subject: [PATCH 05/31] docs(mxc-etw): note cmd_line is captured raw with no privacy filtering Review item #3 (Shailendra): add a PRIVACY NOTE on map_process_launch stating cmd_line is copied verbatim into OCSF process.cmd_line with no redaction, so secrets/PII on a command line land unredacted in the durable audit trail (deliberate audit-fidelity trade-off; treat the log as sensitive). Redaction is owned by an upstream privacy layer, not this path; no general audit-output PII scrubber exists today (openshell_core::secrets [CREDENTIAL] redaction is scoped to the proxy HTTP-target logging, a separate egress path). Signed-off-by: Akber Raza --- crates/openshell-driver-mxc/src/etw_consumer.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/openshell-driver-mxc/src/etw_consumer.rs b/crates/openshell-driver-mxc/src/etw_consumer.rs index 856a79a168..d91d46f34e 100644 --- a/crates/openshell-driver-mxc/src/etw_consumer.rs +++ b/crates/openshell-driver-mxc/src/etw_consumer.rs @@ -1254,6 +1254,20 @@ fn map_config_state(ctx: &SandboxContext, ev: &DecodedEtwEvent) -> OcsfEvent { } /// `CreateProcessInSandbox` (populated) → Process Activity [1007] "Launch". +/// +/// PRIVACY NOTE (review item #3): `cmd_line` is copied **verbatim** from MXC's +/// ETW event into the OCSF `process.cmd_line` field. This consumer performs **no +/// privacy/secret filtering** — if a caller passes credentials, tokens, or PII on +/// the command line, they will appear **unredacted** in the durable audit trail. +/// This is deliberate (audit fidelity), so the OCSF log must be treated as +/// sensitive at rest and in transit. +/// +/// Redaction is intentionally **not** done here and is owned by an upstream +/// privacy layer, not the ETW→OCSF path. Note that no general PII/secret scrubber +/// covers this field today: the only redaction that exists +/// (`openshell_core::secrets`, `${…}` → `[CREDENTIAL]`) is scoped to the network +/// proxy's HTTP-target logging, a separate egress path. If/when a general +/// audit-output PII filter lands, this field is where it must apply. fn map_process_launch(ctx: &SandboxContext, ev: &DecodedEtwEvent, cmd_line: &str) -> OcsfEvent { // The created process's own pid isn't in this event (it appears later in // `ProcessLaunched`); the emitting pid is the sandbox host (wxc-exec). From 65bb41f16e436e609cb99f687bb7266ac6c8e618 Mon Sep 17 00:00:00 2001 From: Jamie King Date: Tue, 14 Jul 2026 14:32:16 -0600 Subject: [PATCH 06/31] fix(mxc-etw): open ETW trace on caller thread so start_session reports real status Review item #4 (Shailendra): start_session previously returned Ok(EtwSession) as soon as the pump thread was spawned, but OpenTraceW ran later inside that thread; if it failed we still handed back a live-looking session and logged 'consumer started' (silent failure = false audit coverage). Split the two Win32 calls instead of adding a channel handshake (avoids any lost-wakeup/hang risk): the quick, synchronous OpenTraceW now runs on the caller thread (open_trace), and only the blocking ProcessTrace runs on the pump thread (run_trace). start_session returns Err if OpenTraceW fails (reclaiming the boxed Sender so the consumer disconnects, stopping the session, joining the consumer) and returns Ok/logs 'started' only once capture is genuinely open. Opened handle + LoggerName buffer + boxed Sender are carried to the pump via a Send OpenedTrace so they outlive ProcessTrace. Box-verified on 7F203-MXC-001: consumer started=True, failed-to-start=False, 50 OCSF rows / 50 JSONL, BuffersLost=0 (no regression to capture/emit). Signed-off-by: Akber Raza --- .../openshell-driver-mxc/src/etw_consumer.rs | 104 ++++++++++++++---- 1 file changed, 81 insertions(+), 23 deletions(-) diff --git a/crates/openshell-driver-mxc/src/etw_consumer.rs b/crates/openshell-driver-mxc/src/etw_consumer.rs index d91d46f34e..da707851df 100644 --- a/crates/openshell-driver-mxc/src/etw_consumer.rs +++ b/crates/openshell-driver-mxc/src/etw_consumer.rs @@ -62,8 +62,9 @@ use windows::Win32::System::Diagnostics::Etw::{ CONTROLTRACE_HANDLE, CloseTrace, ControlTraceW, EVENT_HEADER, EVENT_HEADER_EXTENDED_DATA_ITEM, EVENT_PROPERTY_INFO, EVENT_RECORD, EVENT_TRACE_CONTROL_STOP, EVENT_TRACE_LOGFILEW, EVENT_TRACE_PROPERTIES, EVENT_TRACE_REAL_TIME_MODE, EnableTraceEx2, OpenTraceW, - PROCESS_TRACE_MODE_EVENT_RECORD, PROCESS_TRACE_MODE_REAL_TIME, ProcessTrace, StartTraceW, - TRACE_EVENT_INFO, TRACE_LEVEL_VERBOSE, TdhGetEventInformation, WNODE_FLAG_TRACED_GUID, + PROCESS_TRACE_MODE_EVENT_RECORD, PROCESS_TRACE_MODE_REAL_TIME, PROCESSTRACE_HANDLE, + ProcessTrace, StartTraceW, TRACE_EVENT_INFO, TRACE_LEVEL_VERBOSE, TdhGetEventInformation, + WNODE_FLAG_TRACED_GUID, }; use windows::core::{GUID, PCWSTR, PWSTR}; @@ -247,11 +248,22 @@ impl Drop for EtwSession { } } -/// Wrapper to move a raw `Sender` pointer across the thread boundary into the -/// blocking `ProcessTrace` worker. SAFETY: the boxed `Sender` lives until the -/// worker reclaims it after `ProcessTrace` returns. -struct SendPtr(*mut mpsc::Sender); -unsafe impl Send for SendPtr {} +/// A successfully-opened real-time trace, handed to the pump thread to run the +/// blocking `ProcessTrace`. Produced by [`open_trace`] on the *caller* thread so +/// an `OpenTraceW` failure is surfaced synchronously (review #4) rather than +/// dying silently on the worker after `start_session` already returned `Ok`. +/// +/// SAFETY (`Send`): the contained raw `Sender` pointer and trace handle are only +/// ever touched by the single pump thread that takes ownership of this struct; +/// the boxed `Sender` lives until that thread reclaims it after `ProcessTrace` +/// returns, and `name` (the `LoggerName` buffer `OpenTraceW` referenced) is kept +/// alive for the whole `ProcessTrace` duration. +struct OpenedTrace { + handle: PROCESSTRACE_HANDLE, + name: Vec, + tx_ptr: *mut mpsc::Sender, +} +unsafe impl Send for OpenedTrace {} // --------------------------------------------------------------------------- // Public API @@ -307,14 +319,37 @@ pub(crate) fn start_session(index: Arc>) -> Result o, + Err(e) => { + unsafe { drop(Box::from_raw(tx_ptr)) }; + stop_session(handle); + let _ = consumer_thread.join(); + return Err(e); + } + }; + + let pump_thread = match std::thread::Builder::new() .name("etw-ocsf-pump".into()) - .spawn(move || process_trace_loop(send_ptr)) - .map_err(|e| { + .spawn(move || run_trace(opened)) + { + Ok(t) => t, + Err(e) => { + // The trace is open but we couldn't spawn the pump. Stop the session, + // reclaim the boxed Sender so the consumer disconnects, and join it. + unsafe { drop(Box::from_raw(tx_ptr)) }; stop_session(handle); - format!("failed to spawn ETW pump thread: {e}") - })?; + let _ = consumer_thread.join(); + return Err(format!("failed to spawn ETW pump thread: {e}")); + } + }; tracing::info!( session = SESSION_NAME, @@ -473,9 +508,13 @@ fn cleanup_stale_session() { // ProcessTrace loop (dedicated blocking thread) // --------------------------------------------------------------------------- +/// Open the real-time consumer with `OpenTraceW` on the **caller** thread so the +/// result is synchronous (review #4). `tx_ptr` is the boxed event `Sender`; on +/// failure the caller reclaims it (we do not drop it here). On success the boxed +/// `Sender` and the `LoggerName` buffer are handed to the returned [`OpenedTrace`] +/// so they outlive the subsequent blocking `ProcessTrace`. #[allow(clippy::field_reassign_with_default)] -fn process_trace_loop(send_ptr: SendPtr) { - let tx_ptr = send_ptr.0; +fn open_trace(tx_ptr: *mut mpsc::Sender) -> Result { let mut name = session_name_wide(); let mut logfile = EVENT_TRACE_LOGFILEW::default(); @@ -485,20 +524,39 @@ fn process_trace_loop(send_ptr: SendPtr) { logfile.Anonymous2.EventRecordCallback = Some(event_record_callback); logfile.Context = tx_ptr.cast::(); - let trace_handle = unsafe { OpenTraceW(&mut logfile) }; - if trace_handle.Value == u64::MAX { - tracing::error!(err = %std::io::Error::last_os_error(), "ETW OpenTraceW failed"); - // Reclaim the boxed Sender so the consumer thread's channel closes. - unsafe { drop(Box::from_raw(tx_ptr)) }; - return; + let handle = unsafe { OpenTraceW(&mut logfile) }; + if handle.Value == u64::MAX { + return Err(format!( + "ETW OpenTraceW failed: {}", + std::io::Error::last_os_error() + )); } - let _ = unsafe { ProcessTrace(&[trace_handle], None, None) }; + Ok(OpenedTrace { + handle, + name, + tx_ptr, + }) +} + +/// Run the blocking `ProcessTrace` pump for an already-opened trace, then clean +/// up. Owns [`OpenedTrace`] for its whole lifetime so the `LoggerName` buffer and +/// boxed `Sender` stay valid until `ProcessTrace` returns. +fn run_trace(opened: OpenedTrace) { + let OpenedTrace { + handle, + name, + tx_ptr, + } = opened; + + let _ = unsafe { ProcessTrace(&[handle], None, None) }; unsafe { - let _ = CloseTrace(trace_handle); + let _ = CloseTrace(handle); drop(Box::from_raw(tx_ptr)); } + // Keep the LoggerName buffer alive until ProcessTrace has fully returned. + drop(name); } unsafe extern "system" fn event_record_callback(event_record: *mut EVENT_RECORD) { From b28465c953e8d84cbdd60aa95d6bb8cbcc4fd489 Mon Sep 17 00:00:00 2001 From: Jamie King Date: Tue, 14 Jul 2026 14:51:41 -0600 Subject: [PATCH 07/31] fix(mxc-etw): guard pending-event replay against PID recycling CodeRabbit flagged that drain_resolved() re-resolved buffered events against the live by_pid map, so if Windows recycled a wxc-exec PID within PENDING_TTL a stale event from the dead sandbox could be emitted under the new owner. Stamp each by_pid registration with its Instant and add resolve_replay(), used only on the buffered/replay path. It (a) never falls back to the recycle-/ambiguity-prone by_cmd or last_pid_sid keys, and (b) trusts a PID match only when the registration is not newer than the buffered event by more than REPLAY_PID_GRACE (2s) - a recycled PID's registration lands well outside that window, so the stale event ages out instead of misattributing. The legitimate #2 seed race (registration lands ~immediately) still replays. Adds unit tests for the recycle-refusal, in-grace acceptance, and weak-fallback exclusion. Signed-off-by: Akber Raza --- .../openshell-driver-mxc/src/etw_consumer.rs | 165 ++++++++++++++++-- 1 file changed, 152 insertions(+), 13 deletions(-) diff --git a/crates/openshell-driver-mxc/src/etw_consumer.rs b/crates/openshell-driver-mxc/src/etw_consumer.rs index da707851df..06a8467e5e 100644 --- a/crates/openshell-driver-mxc/src/etw_consumer.rs +++ b/crates/openshell-driver-mxc/src/etw_consumer.rs @@ -904,9 +904,27 @@ const PENDING_MAX: usize = 4096; /// unattributable (e.g. an unrelated Sandboxing-provider consumer on the box). const PENDING_TTL: Duration = Duration::from_secs(5); +/// Grace window for trusting a PID match when *replaying* a buffered event. +/// The driver seeds `by_pid` within milliseconds of spawning `wxc-exec`, so a +/// legitimate seed event's registration lands at (or just after) the moment the +/// event was buffered. A recycled PID, by contrast, requires the prior +/// `wxc-exec` to exit and a new one to spawn — far longer than this window — so +/// a registration that is newer than the buffered event by more than this grace +/// is treated as a *different* (recycled) owner and the PID match is refused. +const REPLAY_PID_GRACE: Duration = Duration::from_secs(2); + +/// A `wxc-exec` PID registration: which sandbox owns the PID and *when* it was +/// registered. The timestamp lets the replay path (see [`AttributionIndex:: +/// resolve_replay`]) reject a PID that was recycled to a different sandbox after +/// a still-buffered event was captured. +struct PidReg { + sid: String, + at: Instant, +} + #[derive(Default)] pub(crate) struct AttributionIndex { - by_pid: HashMap, + by_pid: HashMap, by_identity: HashMap, by_activity: HashMap, by_cv: HashMap, @@ -952,17 +970,23 @@ impl AttributionIndex { // and Windows has recycled the number. Rebind to the new owner and drop // the stale per-PID "last resolved" hint so it can't misroute. if let Some(prev) = self.by_pid.get(&wxc_pid) { - if prev != sandbox_id { + if prev.sid != sandbox_id { tracing::warn!( target: "mxc_etw", pid = wxc_pid, - prev = %prev, + prev = %prev.sid, new = %sandbox_id, "wxc-exec PID reused before prior sandbox was forgotten; rebinding attribution" ); } } - self.by_pid.insert(wxc_pid, sandbox_id.to_string()); + self.by_pid.insert( + wxc_pid, + PidReg { + sid: sandbox_id.to_string(), + at: Instant::now(), + }, + ); self.last_pid_sid.remove(&wxc_pid); // Command line is only trustworthy while unique. Promote to `by_cmd` on @@ -987,7 +1011,7 @@ impl AttributionIndex { /// Drop all keys for a finished sandbox to bound memory. pub fn forget(&mut self, sandbox_id: &str) { - self.by_pid.retain(|_, v| v != sandbox_id); + self.by_pid.retain(|_, r| r.sid != sandbox_id); self.by_identity.retain(|_, v| v != sandbox_id); self.by_activity.retain(|_, v| v != sandbox_id); self.by_cv.retain(|_, v| v != sandbox_id); @@ -1023,7 +1047,7 @@ impl AttributionIndex { let sid = self .by_pid .get(&ev.process_id) - .cloned() + .map(|registration| registration.sid.clone()) .or_else(|| { identity .as_ref() @@ -1038,18 +1062,78 @@ impl AttributionIndex { .or_else(|| cmd.as_ref().and_then(|c| self.by_cmd.get(c).cloned())) .or_else(|| self.last_pid_sid.get(&ev.process_id).cloned())?; + self.cross_link(&sid, identity, cv, activity, ev.process_id); + Some(sid) + } + + /// Resolve a *buffered* (replayed) event. Unlike [`Self::resolve`], this is + /// hardened against PID recycling and command-line ambiguity that can occur + /// during the buffer window ([`PENDING_TTL`]): + /// + /// - It **never** falls back to `by_cmd` or `last_pid_sid` — both are + /// recycle-/ambiguity-prone and a stale entry could bind a buffered event + /// to the wrong sandbox. + /// - A `by_pid` match is only trusted if the PID's registration is not newer + /// than the buffered event by more than [`REPLAY_PID_GRACE`]. If the PID + /// was recycled to a *different* sandbox after this event was captured, the + /// registration timestamp will be well beyond the grace window and the PID + /// match is refused (the event stays buffered and ages out rather than + /// being misattributed to the new owner). + /// + /// Strong, per-sandbox-unique correlators (`identity`, `activity`, CV) are + /// always trusted — they are cross-linked from the driver-owned PID anchor + /// and are not reused across sandboxes. + fn resolve_replay(&mut self, ev: &DecodedEtwEvent, buffered_at: Instant) -> Option { + let identity = ev.identity(); + let cv = ev.cv_base(); + let activity = guid_key(&ev.activity_id); + + let sid = identity + .as_ref() + .and_then(|i| self.by_identity.get(i).cloned()) + .or_else(|| { + activity + .as_ref() + .and_then(|a| self.by_activity.get(a).cloned()) + }) + .or_else(|| cv.as_ref().and_then(|c| self.by_cv.get(c).cloned())) + .or_else(|| { + self.by_pid.get(&ev.process_id).and_then(|r| { + // Refuse a PID that was (re)registered well after this event + // was buffered — that registration belongs to a recycled PID + // owned by a different sandbox, not this event's emitter. + if r.at <= buffered_at + REPLAY_PID_GRACE { + Some(r.sid.clone()) + } else { + None + } + }) + })?; + + self.cross_link(&sid, identity, cv, activity, ev.process_id); + Some(sid) + } + + /// Cross-link the strong keys an event carries to its resolved `sandbox_id` + /// so later keyless events for the same sandbox attribute correctly. + fn cross_link( + &mut self, + sid: &str, + identity: Option, + cv: Option, + activity: Option, + pid: u32, + ) { if let Some(i) = identity { - self.by_identity.entry(i).or_insert_with(|| sid.clone()); + self.by_identity.entry(i).or_insert_with(|| sid.to_string()); } if let Some(c) = cv { - self.by_cv.entry(c).or_insert_with(|| sid.clone()); + self.by_cv.entry(c).or_insert_with(|| sid.to_string()); } if let Some(a) = activity { - self.by_activity.entry(a).or_insert_with(|| sid.clone()); + self.by_activity.entry(a).or_insert_with(|| sid.to_string()); } - self.last_pid_sid.insert(ev.process_id, sid.clone()); - - Some(sid) + self.last_pid_sid.insert(pid, sid.to_string()); } /// Hold an event that didn't resolve yet, evicting expired and (if needed) @@ -1091,7 +1175,7 @@ impl AttributionIndex { tracing::debug!(target: "mxc_etw", pid = p.ev.process_id, "dropping unattributed (aged out) {}", p.ev.summary()); continue; } - match self.resolve(&p.ev) { + match self.resolve_replay(&p.ev, p.at) { Some(sid) => { let name = self.name_of(&sid); ready.push((sid, name, p.ev)); @@ -1651,4 +1735,59 @@ mod tests { .push(("commandLine".into(), "\"agent --unique\"".into())); assert_eq!(idx.resolve(&uniq).as_deref(), Some("sbx-3")); } + + // CodeRabbit (replay PID recycle): a buffered event whose only key is a PID + // must NOT be replayed onto a sandbox that registered that PID *after* the + // event was captured — that registration is a recycled PID owned by someone + // else. Refusing (event ages out) beats misattributing to the new owner. + #[test] + fn replayed_pid_match_refused_after_recycle() { + let mut idx = AttributionIndex::new(); + // Stale event for a now-dead sandbox, buffered a while ago. + let ev = mk_event(1000, "CreateProcessInSandbox"); + let buffered_at = Instant::now() - Duration::from_secs(3); + + // PID 1000 is recycled and registered to a brand-new sandbox *now*. + idx.register_launch("sbx-new", "new", 1000, "agent"); + + assert!( + idx.resolve_replay(&ev, buffered_at).is_none(), + "stale PID-only event must not bind to the recycled PID's new owner" + ); + } + + // The legitimate #2 seed race is preserved: an event buffered essentially + // when the driver seeds attribution still replays via its PID. + #[test] + fn replayed_pid_match_accepted_within_grace() { + let mut idx = AttributionIndex::new(); + let ev = mk_event(1000, "CreateProcessInSandbox"); + let buffered_at = Instant::now(); + idx.register_launch("sbx-1", "s1", 1000, "agent"); + assert_eq!( + idx.resolve_replay(&ev, buffered_at).as_deref(), + Some("sbx-1"), + "a seed event buffered at registration time must still replay" + ); + } + + // Replay must not lean on the weak fallbacks (`by_cmd` / `last_pid_sid`): + // a buffered event whose only match is a command line is refused on replay + // (it would be resolved on the live path, but is too weak to trust after a + // buffering delay). + #[test] + fn replay_ignores_weak_fallbacks() { + let mut idx = AttributionIndex::new(); + idx.register_launch("sbx-1", "s1", 11, "agent --unique"); + + let mut only_cmd = mk_event(999, "SandboxConfig"); + only_cmd + .props + .push(("commandLine".into(), "\"agent --unique\"".into())); + + // Live path would resolve it via by_cmd... + // (not asserted here to avoid mutating cross-links) + // ...but the replay path refuses the weak command-line key. + assert!(idx.resolve_replay(&only_cmd, Instant::now()).is_none()); + } } From 661270a9202691b1d69b25e028c7abd59d89c5f2 Mon Sep 17 00:00:00 2001 From: Jamie King Date: Tue, 14 Jul 2026 15:12:40 -0600 Subject: [PATCH 08/31] fix(mxc-etw): surface unexpected ProcessTrace termination (review #4) start_session already returns Err on OpenTraceW failure (runs on the caller thread since e41a7701), closing the first half of Shailendra's #4. This closes the second half: ProcessTrace's result was discarded, so if capture died mid-run the backend had no way to know. Add a shared CaptureHealth (stopped/stopping/exit_code) between the pump thread and EtwSession. run_trace now records ProcessTrace's WIN32_ERROR and, when the pump returns without a deliberate stop, logs at ERROR that MXC OCSF capture is no longer running. EtwSession::stop() sets `stopping` before teardown so a normal shutdown isn't misreported, and EtwSession::is_capture_alive() exposes the state for status/diagnostics. Box-verified on 7F203-MXC-001: 5 sandboxes, 50 attributed OCSF rows, JSONL parity 50/50, BuffersLost=0, clean start/stop (no false failure). Signed-off-by: Akber Raza --- .../openshell-driver-mxc/src/etw_consumer.rs | 64 ++++++++++++++++++- 1 file changed, 61 insertions(+), 3 deletions(-) diff --git a/crates/openshell-driver-mxc/src/etw_consumer.rs b/crates/openshell-driver-mxc/src/etw_consumer.rs index 06a8467e5e..8303ba8ea9 100644 --- a/crates/openshell-driver-mxc/src/etw_consumer.rs +++ b/crates/openshell-driver-mxc/src/etw_consumer.rs @@ -52,6 +52,7 @@ use std::collections::{HashMap, VecDeque}; use std::ffi::c_void; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::mpsc; use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; @@ -215,17 +216,39 @@ impl DecodedEtwEvent { // Session handle (RAII) // --------------------------------------------------------------------------- +/// Health of the blocking `ProcessTrace` pump, shared between the pump thread and +/// the owning [`EtwSession`] (review #4). Previously `ProcessTrace`'s result was +/// discarded, so if capture died mid-run (e.g. the session was stopped out from +/// under us) the backend had no way to know. The pump records its outcome here so +/// an *unexpected* termination is logged at ERROR and can be queried via +/// [`EtwSession::is_capture_alive`]. +#[derive(Default)] +struct CaptureHealth { + /// Set once the pump's `ProcessTrace` has returned (capture is no longer running). + stopped: AtomicBool, + /// Set by [`EtwSession::stop`] *before* stopping the session, so a deliberate + /// shutdown isn't misreported as a capture failure. + stopping: AtomicBool, + /// The `WIN32_ERROR` code `ProcessTrace` returned (0 == `ERROR_SUCCESS`). + /// Only meaningful once `stopped` is set. + exit_code: AtomicU32, +} + /// A running real-time ETW session plus its worker threads. Dropping (or calling /// [`EtwSession::stop`]) stops the session and joins the threads. pub(crate) struct EtwSession { handle: u64, pump_thread: Option>, consumer_thread: Option>, + health: Arc, } impl EtwSession { /// Stop the session and join worker threads. Idempotent. pub fn stop(&mut self) { + // Mark the stop as expected *before* triggering it so the pump thread's + // `ProcessTrace` return isn't logged as an unexpected capture death. + self.health.stopping.store(true, Ordering::SeqCst); if self.handle != 0 { stop_session(self.handle); self.handle = 0; @@ -240,6 +263,14 @@ impl EtwSession { let _ = t.join(); } } + + /// Whether the `ProcessTrace` pump is still running. Returns `false` once the + /// pump has returned — whether from a deliberate [`stop`](Self::stop) or an + /// unexpected termination. Exposed so the backend can surface capture health + /// in status/diagnostics (review #4). + pub fn is_capture_alive(&self) -> bool { + !self.health.stopped.load(Ordering::SeqCst) + } } impl Drop for EtwSession { @@ -336,9 +367,11 @@ pub(crate) fn start_session(index: Arc>) -> Result t, Err(e) => { @@ -360,6 +393,7 @@ pub(crate) fn start_session(index: Arc>) -> Result) -> Result) { let OpenedTrace { handle, name, tx_ptr, } = opened; - let _ = unsafe { ProcessTrace(&[handle], None, None) }; + let status = unsafe { ProcessTrace(&[handle], None, None) }; + + // Record the outcome before any cleanup so a health query never races a + // still-"alive" state after the pump has actually returned. + health.exit_code.store(status.0, Ordering::SeqCst); + health.stopped.store(true, Ordering::SeqCst); + + let expected = health.stopping.load(Ordering::SeqCst); + if !expected { + // The session went away without anyone asking it to (e.g. an external + // `logman stop`, a provider error, or a dropped trace). Surface it — the + // OCSF audit trail is now blind until the driver is restarted. + tracing::error!( + target: "mxc_etw", + code = status.0, + "ETW ProcessTrace terminated unexpectedly; MXC OCSF capture is no longer running" + ); + } else { + tracing::debug!(target: "mxc_etw", code = status.0, "ETW ProcessTrace returned after stop"); + } unsafe { let _ = CloseTrace(handle); From 26fd1132cb7a4b7f74ceedbf947c0b5fffdbc29b Mon Sep 17 00:00:00 2001 From: Jamie King Date: Tue, 14 Jul 2026 22:26:38 -0600 Subject: [PATCH 09/31] =?UTF-8?q?=EF=BB=BFfeat(mxc-ocsf):=20add=20ETW->OCS?= =?UTF-8?q?F=20audit-trail=20example=20kit;=20fix=20proxy-configured=20mes?= =?UTF-8?q?sage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a runnable OCSF audit-trail example under examples/ (run-ocsf-audit.ps1, mxc-ocsf-audit.toml, ocsf-audit.yaml, README) that spins up sandboxes with the in-process ETW consumer and egress proxy on, emitting a full OCSF JSONL audit trail across all four classes (6002/5019/1007/2004). Fix SandboxProxyConfigured mapping to log "MXC sandbox proxy configured" instead of a misleading "(no proxy)" when the provider reports proxyPort=0; the event's presence already indicates proxy configuration. Verified on-box: 26 events, all mapped ETW event types present. Signed-off-by: Akber Raza --- .../examples/README-ocsf-audit.txt | 75 ++++ .../examples/mxc-ocsf-audit.toml | 54 +++ .../examples/ocsf-audit.yaml | 19 + .../examples/run-ocsf-audit.ps1 | 351 ++++++++++++++++++ .../openshell-driver-mxc/src/etw_consumer.rs | 21 +- 5 files changed, 516 insertions(+), 4 deletions(-) create mode 100644 crates/openshell-driver-mxc/examples/README-ocsf-audit.txt create mode 100644 crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml create mode 100644 crates/openshell-driver-mxc/examples/ocsf-audit.yaml create mode 100644 crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 diff --git a/crates/openshell-driver-mxc/examples/README-ocsf-audit.txt b/crates/openshell-driver-mxc/examples/README-ocsf-audit.txt new file mode 100644 index 0000000000..26d4af2ef3 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/README-ocsf-audit.txt @@ -0,0 +1,75 @@ +OpenShell MXC - ETW -> OCSF audit-trail example +=============================================== + +WHAT THIS PROVES / PRODUCES + The full Windows OCSF audit path on this box: + gateway -> MXC driver -> process_container sandbox + -> the OS "Sandboxing" ETW provider fires as the sandbox is created + -> the gateway's in-process consumer decodes each event, attributes it to + an OpenShell sandbox_id, and maps it to OCSF + -> events are written to a durable JSONL audit log AND printed as + human-readable shorthand. + + The deliverable is the OCSF log: openshell-ocsf..log, one OCSF event + object per line - the same schema and medium the Linux OpenShell pipeline + produces (Windows is at functional parity). + + OCSF classes you will see: + [6002] Application Lifecycle - sandbox created + [5019] Device Config State Change - OS policy / hardening / proxy / console + [1007] Process Activity - in-sandbox process launch (+ cmd line) + [2004] Detection Finding - MXC setup activity errors (informational) + +PREREQUISITES (on this test box) + - wxc-exec.exe present (default expected: C:\mxc-kit\bin\wxc-exec.exe) + - process_container backend live (it was for our earlier runs) + - Run ELEVATED (Run as administrator) OR from an account in the + 'Performance Log Users' group. Opening the real-time ETW session needs this; + without it the run fails fast with a clear message. + +HOW TO RUN + 1. Open an ELEVATED PowerShell in THIS folder. + 2. Run: + powershell -NoProfile -ExecutionPolicy Bypass -File .\run-ocsf-audit.ps1 + If wxc-exec is somewhere else: + ... -File .\run-ocsf-audit.ps1 -WxcExecPath "D:\path\to\wxc-exec.exe" + +WHAT YOU GET BACK + The script prints PASS/FAIL + a class/event breakdown and creates: + results-.zip + Hand that zip back. It contains the OCSF audit log (openshell-ocsf..log), + the full transcript, the gateway logs (with the human-readable OCSF shorthand), + a summary, and the exact config + policy used. The bundle is also auto-copied + to the shared drive for pickup (pass -ShareOut "" to disable that). + +FILES IN THIS PACKAGE + openshell-gateway.exe the gateway (self-contained; needs only VC++ runtime) + openshell.exe the CLI + mxc-ocsf-audit.toml gateway/driver config (process_container, etw_audit=true, egress proxy) + ocsf-audit.yaml sandbox policy (read-write grant to the share dir) + run-ocsf-audit.ps1 the orchestrator you run + README-ocsf-audit.txt this file + (wxc-exec.exe is used IN PLACE on the box; not shipped) + +USEFUL OPTIONS + -SandboxCount Create n sandboxes (default 2). More sandboxes = more events. + -NoProxy Skip the per-sandbox egress proxy. This omits ONLY the + SandboxProxyConfigured config event; everything else is + still produced. (Default is proxy ON for the full set.) + -WxcExecPath Path to wxc-exec.exe on this box. + -ShareOut "" Disable the auto-copy of the results bundle to the share. + -KeepRunning Leave the gateway running afterward for inspection. + +NOTES + - The control plane between CLI and gateway runs with --disable-tls on loopback; + that is unrelated to the OCSF audit path this example exercises. + - A "supervisor session not connected" / ssh 255 message during sandbox create + is EXPECTED on MXC and harmless - the agent already ran in-driver. + - The proxy path requires the host-side CONNECT proxy and an absolute agent + binary (the packaged config uses C:\Windows\System32\cmd.exe); the run script + handles this for you. + - The Sandboxing provider reports the sandbox entry-point process, not the full + in-sandbox process tree. Deep process-tree auditing would need a second ETW + source (Microsoft-Windows-Kernel-Process) and is out of scope for this trail. + - cmd_line is captured verbatim into OCSF process.cmd_line with no redaction on + this path; treat the audit log as sensitive at rest and in transit. diff --git a/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml b/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml new file mode 100644 index 0000000000..d41e08efe3 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml @@ -0,0 +1,54 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# MXC gateway config for the ETW -> OCSF audit-trail example. +# +# Goal: exercise the in-process ETW consumer (Plane A) end-to-end so that +# creating a sandbox produces a full OCSF audit trail — Application Lifecycle +# [6002], Device Config State Change [5019], Process Activity [1007] and +# Detection Finding [2004] — written to a durable JSONL log, just like the Linux +# OCSF pipeline. +# +# run-ocsf-audit.ps1 patches wxc_exec_path, backend, etw_audit, the egress-proxy +# switch and agent_command into a disposable copy of this file, so the values +# here are sane defaults; edit them if you run the gateway directly. + +[openshell.drivers.mxc] +# Path to wxc-exec.exe on the box (patched by the run script; default is the +# location observed on the MXC test boxes). +wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe" + +# One-shot AppContainer. This is the backend whose Sandboxing ETW the consumer +# captures. (isolation_session is "dark" — it emits no provider events.) +backend = "process_container" + +default_configuration_id = "composable" + +# Host folder mapped read-write into the sandbox. +share_dir = "C:/work/openshell-mxc-demo" +agent_cwd = "C:/work/openshell-mxc-demo" + +# A simple in-policy write — enough to make wxc-exec provision an AppContainer and +# drive the Sandboxing provider. Absolute cmd.exe path is REQUIRED when the egress +# proxy is on (the host proxy hashes agent_command[0] as its static identity +# binary, so it must be an absolute, existing exe). +agent_command = [ + "C:\\Windows\\System32\\cmd.exe", + "/c", + "echo hello from openshell ocsf audit 1>C:\\work\\openshell-mxc-demo\\hello.txt", +] + +debug = false + +# Turn ON the Plane-A ETW -> OCSF audit consumer. This is the core of the example. +etw_audit = true + +# Per-sandbox governed egress. Enabling this makes the driver start a host CONNECT +# proxy and hand MXC a `network.proxy` redirect, which is what makes MXC emit the +# SandboxProxyConfigured event — the config event mapped to OCSF CONFIG [5019] +# that completes full event coverage. Requires backend = process_container and a +# loopback (127.0.0.1) seed address; the driver allocates a unique ephemeral port +# per sandbox from this seed. Run-ocsf-audit.ps1 disables this when passed +# -NoProxy. +egress_proxy = true +egress_proxy_addr = "127.0.0.1:18080" diff --git a/crates/openshell-driver-mxc/examples/ocsf-audit.yaml b/crates/openshell-driver-mxc/examples/ocsf-audit.yaml new file mode 100644 index 0000000000..ade2f69eec --- /dev/null +++ b/crates/openshell-driver-mxc/examples/ocsf-audit.yaml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# ocsf-audit.yaml — sandbox policy for the MXC ETW -> OCSF audit-trail example. +# +# Minimal filesystem policy granting the shared host folder read-write; everything +# else is default-deny. The granted path MUST match `share_dir` / +# OPENSHELL_MXC_SHARE_DIR in mxc-ocsf-audit.toml. +# +# No network_policies block is needed here: the per-sandbox egress proxy is driven +# by `egress_proxy = true` in mxc-ocsf-audit.toml (that is what makes MXC emit the +# SandboxProxyConfigured event we map to OCSF), not by a policy rule. +version: 1 + +filesystem_policy: + include_workdir: false + read_only: [] + read_write: + - "C:/work/openshell-mxc-demo" diff --git a/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 new file mode 100644 index 0000000000..8c723a84e6 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 @@ -0,0 +1,351 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# run-ocsf-audit.ps1 - gateway-driven ETW -> OCSF audit-trail example for OpenShell/MXC. +# +# Proves the FULL product path on the test box AND produces a durable OCSF log: +# start gateway (etw_audit on, OCSF JSONL on) -> register CLI -> +# create N sandboxes (each drives the OS "Sandboxing" ETW provider) -> +# the in-process consumer decodes, attributes, and maps every event to OCSF -> +# tear the sandboxes + gateway down -> collect the OCSF log + every artifact +# into a results\ folder -> zip it. +# +# The deliverable is the OCSF audit log itself: openshell-ocsf..log, a +# durable JSONL file with one OCSF event object per line - the same schema and +# medium the Linux OpenShell pipeline produces. +# +# MUST RUN ELEVATED. Opening the real-time ETW session requires an elevated shell +# (Run as administrator) or an account in the 'Performance Log Users' group. +# +# Run from inside the package folder (gateway + cli + mxc-ocsf-audit.toml + +# ocsf-audit.yaml + this script all sit together): +# +# powershell -NoProfile -ExecutionPolicy Bypass -File .\run-ocsf-audit.ps1 ` +# -WxcExecPath C:\mxc-kit\bin\wxc-exec.exe +# +# By default the per-sandbox egress proxy is ON so the full event set (including +# SandboxProxyConfigured) is produced. Pass -NoProxy to omit only that one event. +# +# Hand the produced results-*.zip back for evaluation. + +[CmdletBinding()] +param( + # Real wxc-exec on the test box. + [string] $WxcExecPath = "C:\mxc-kit\bin\wxc-exec.exe", + # Host folder mapped read-write into the sandbox (must match ocsf-audit.yaml). + [string] $ShareDir = "C:\work\openshell-mxc-demo", + # How many sandboxes to create (each drives a full event burst). + [int] $SandboxCount = 2, + # Disable the per-sandbox egress proxy (omits the SandboxProxyConfigured event). + [switch] $NoProxy, + # Gateway bind port (matches the gateway default) + CLI registration name. + [int] $Port = 17670, + [string] $GatewayName = "openshell-mxc-ocsf", + # Internal driver ETW session name (used to clean up a leaked session). + [string] $SessionName = "OpenShell-MXC-ETW", + # Shared drive the results bundle is auto-copied to for pickup/analysis. + # Set to "" to disable the push. + [string] $ShareOut = "\\nvsw-dump\users\jamiek\prashant", + # Leave the gateway running afterward (for inspection). + [switch] $KeepRunning +) + +$ErrorActionPreference = "Stop" +# Don't let expected non-zero CLI exits (e.g. the post-create attach) throw on PS 7.4+. +$PSNativeCommandUseErrorActionPreference = $false +try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {} +$OutputEncoding = [System.Text.Encoding]::UTF8 + +$here = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path } + +# Results bundle (everything we hand back) ------------------------------------ +$stamp = Get-Date -Format "yyyyMMdd-HHmmss" +$resultDir = Join-Path $here "results-$stamp" +New-Item -ItemType Directory -Force $resultDir | Out-Null +Start-Transcript -Path (Join-Path $resultDir "transcript.txt") -Force | Out-Null + +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 } + +$gateway = Join-Path $here "openshell-gateway.exe" +$cli = Join-Path $here "openshell.exe" +$policy = Join-Path $here "ocsf-audit.yaml" +$tomlSrc = Join-Path $here "mxc-ocsf-audit.toml" +$toml = Join-Path $resultDir "mxc-ocsf-audit.used.toml" # disposable patched copy (bundled) + +$gw = $null +$passed = $true +$proxyOn = -not $NoProxy + +try { + # 1. Validate artifacts + privilege. + Step "Validate package artifacts" + foreach ($f in @($gateway, $cli, $policy, $tomlSrc)) { + if (-not (Test-Path $f)) { throw "missing artifact: $f (run this script from inside the package folder)" } + Info "found $(Split-Path $f -Leaf)" + } + Info "machine : $env:COMPUTERNAME user: $env:USERNAME PS: $($PSVersionTable.PSVersion)" + + # Opening the real-time ETW session requires elevation or 'Performance Log Users'. + $wid = [Security.Principal.WindowsIdentity]::GetCurrent() + $wp = New-Object Security.Principal.WindowsPrincipal($wid) + $admin = $wp.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + $plu = $wp.IsInRole((New-Object Security.Principal.SecurityIdentifier("S-1-5-32-559"))) + Info "elevated=$admin perfLogUsers=$plu" + if (-not $admin -and -not $plu) { + throw "This run must open a real-time ETW session, which needs elevation. Re-run from an elevated shell (Run as administrator) or add this account to 'Performance Log Users'." + } + + if (-not (Test-Path $WxcExecPath)) { + throw "wxc-exec not found at '$WxcExecPath'. Pass -WxcExecPath pointing at the real binary." + } + Info "wxc-exec: $WxcExecPath" + + # 2. Patch the disposable TOML copy: wxc path + backend + etw_audit + egress. + Step "Patch gateway config (disposable copy)" + $tomlText = Get-Content $tomlSrc -Raw + $escaped = $WxcExecPath.Replace('\', '\\') + $tomlText = [regex]::Replace($tomlText, '(?m)^\s*#?\s*wxc_exec_path\s*=.*$', "wxc_exec_path = `"$escaped`"") + $tomlText = [regex]::Replace($tomlText, '(?m)^\s*#?\s*backend\s*=.*$', 'backend = "process_container"') + if ($tomlText -match '(?m)^\s*#?\s*etw_audit\s*=') { + $tomlText = [regex]::Replace($tomlText, '(?m)^\s*#?\s*etw_audit\s*=.*$', 'etw_audit = true') + } else { + $tomlText = [regex]::Replace($tomlText, '(?m)^\[openshell\.drivers\.mxc\]\s*$', "[openshell.drivers.mxc]`r`netw_audit = true") + } + $proxyVal = if ($proxyOn) { 'true' } else { 'false' } + if ($tomlText -match '(?m)^\s*#?\s*egress_proxy\s*=') { + $tomlText = [regex]::Replace($tomlText, '(?m)^\s*#?\s*egress_proxy\s*=.*$', "egress_proxy = $proxyVal") + } else { + $tomlText = [regex]::Replace($tomlText, '(?m)^\[openshell\.drivers\.mxc\]\s*$', "[openshell.drivers.mxc]`r`negress_proxy = $proxyVal") + } + Set-Content $toml -Value $tomlText -Encoding UTF8 + Copy-Item $policy (Join-Path $resultDir "ocsf-audit.used.yaml") -Force + Info "backend=process_container etw_audit=true egress_proxy=$proxyVal" + + # 3. Port must be free. Auto-clear a stale OUR-gateway; refuse anything else. + Step "Check gateway port $Port is free" + $busy = Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue + if ($busy) { + $owner = Get-Process -Id $busy.OwningProcess -ErrorAction SilentlyContinue + if ($owner -and $owner.Name -eq "openshell-gateway") { + Info "stale gateway on port $Port (pid $($owner.Id)) - stopping it" + Stop-Process -Id $owner.Id -Force -ErrorAction SilentlyContinue + Start-Sleep -Seconds 2 + } else { + throw "port $Port in use by '$($owner.Name)' (pid $($busy.OwningProcess)) - not our gateway; stop it and retry." + } + } + Ok "port $Port free" + + # 4. ETW session pre-flight. A force-killed gateway never runs Drop, so its + # real-time ETW session LEAKS and can starve the next run's capture. Stop + # any leftover before we start. + Step "ETW session pre-flight" + $leaked = @(logman query -ets 2>$null | Select-String -SimpleMatch $SessionName) + Info "leaked '$SessionName' sessions before run: $($leaked.Count)" + if ($leaked.Count -gt 0) { logman stop $SessionName -ets 2>&1 | Out-Null; Info "stopped leaked session(s)" } + + # 5. Prepare share folder. + New-Item -ItemType Directory -Force $ShareDir | Out-Null + Remove-Item (Join-Path $ShareDir "hello.txt") -Force -ErrorAction SilentlyContinue + + # 6. Gateway environment. Enable the durable OCSF JSONL audit sink and point it + # at THIS run's dir so the log lands directly in the bundle. + $env:OPENSHELL_DRIVERS = "mxc" + $env:OPENSHELL_MXC_SHARE_DIR = $ShareDir + $env:OPENSHELL_WXC_EXEC_PATH = $WxcExecPath + $env:OPENSHELL_OCSF_JSON = "1" + $env:OPENSHELL_OCSF_LOG_DIR = $resultDir + Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue + + # 7. Start the gateway (background, TLS disabled on the loopback control plane). + Step "Start gateway (OCSF audit on)" + $gwLog = Join-Path $resultDir "gateway.log" + $gwErrLog = Join-Path $resultDir "gateway.err.log" + $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 -> $(Split-Path $gwLog -Leaf) (+ .err)" + + # 8. Wait until the gateway is 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 logs above." + } + if (Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue) { $ready = $true; break } + Start-Sleep -Milliseconds 500 + } + if (-not $ready) { throw "gateway did not start listening on $Port within 30s." } + Ok "gateway listening on 127.0.0.1:$Port" + + # 9. Register CLI -> gateway. + Step "Register CLI -> gateway" + $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 - likely already registered)" } + try { & $cli gateway select $GatewayName 2>&1 | ForEach-Object { Info $_ } } + catch { Info "gateway select: $($_.Exception.Message) (continuing)" } + Ok "selected gateway '$GatewayName'" + + # 10. Create N sandboxes. Each drives the Sandboxing provider -> a full OCSF + # event burst. The post-create interactive attach failure is EXPECTED on + # MXC (no in-sandbox supervisor) and harmless - the agent already ran. + Step "Create $SandboxCount sandbox(es) (drives the Sandboxing provider)" + for ($i = 1; $i -le $SandboxCount; $i++) { + $name = "ocsf$i" + Info "-- creating $name --" + try { & $cli sandbox create --name $name --policy $policy --no-tty -- exit 2>&1 | ForEach-Object { Info $_ } } + catch { Info "sandbox create attach: $($_.Exception.Message) (expected on MXC - agent ran in-driver; continuing)" } + Start-Sleep -Seconds 3 + try { & $cli sandbox delete $name 2>&1 | Out-Null } catch {} + } +} +catch { + Bad $_.Exception.Message + $passed = $false +} +finally { + # Stop the gateway FIRST so it releases its log + JSONL file handles. + if ($KeepRunning -and $gw -and -not $gw.HasExited) { + Info "leaving gateway pid $($gw.Id) running (-KeepRunning); stop it with: Stop-Process -Id $($gw.Id) -Force" + } elseif ($gw -and -not $gw.HasExited) { + Step "Cleanup" + Stop-Process -Id $gw.Id -Force -ErrorAction SilentlyContinue + try { $gw.WaitForExit(5000) | Out-Null } catch {} + Info "stopped gateway pid $($gw.Id)" + } + # Belt-and-suspenders: force-kill skips Drop, so stop the leaked session here. + if (-not $KeepRunning) { logman stop $SessionName -ets 2>&1 | Out-Null } + + # ---- summarise the OCSF audit trail -------------------------------------- + $logText = @() + if (Test-Path (Join-Path $resultDir "gateway.log")) { $logText += Get-Content (Join-Path $resultDir "gateway.log") } + if (Test-Path (Join-Path $resultDir "gateway.err.log")) { $logText += Get-Content (Join-Path $resultDir "gateway.err.log") } + # The gateway writes ANSI colour codes even when redirected; strip them so + # matches are reliable. + $esc = [char]27 + $logText = $logText | ForEach-Object { $_ -replace "$esc\[[0-9;]*m", "" } + + $consumerStarted = [bool]($logText | Select-String -SimpleMatch "consumer started" -Quiet) + $consumerFailed = [bool]($logText | Select-String -SimpleMatch "ETW audit consumer failed to start" -Quiet) + + # Locate the durable OCSF JSONL audit log and tally by OCSF class. + $jsonlFiles = @(Get-ChildItem -Path $resultDir -Filter "openshell-ocsf*.log" -ErrorAction SilentlyContinue) + $jsonlPath = if ($jsonlFiles.Count) { $jsonlFiles[0].FullName } else { $null } + $classNames = @{ 6002 = "Application Lifecycle"; 5019 = "Device Config State Change"; 1007 = "Process Activity"; 2004 = "Detection Finding" } + $classCounts = @{ 6002 = 0; 5019 = 0; 1007 = 0; 2004 = 0 } + $jsonlCount = 0; $jsonlBad = 0; $sids = @(); $hosts = @() + if ($jsonlPath) { + $raw = @(Get-Content $jsonlPath -ErrorAction SilentlyContinue | Where-Object { $_.Trim() -ne "" }) + $jsonlCount = $raw.Count + foreach ($line in $raw) { + try { + $o = $line | ConvertFrom-Json + if ($o.class_uid -ne $null -and $classCounts.ContainsKey([int]$o.class_uid)) { $classCounts[[int]$o.class_uid]++ } + if ($o.metadata -and $o.metadata.uid) { $sids += [string]$o.metadata.uid } + if ($o.device -and $o.device.hostname) { $hosts += [string]$o.device.hostname } + } catch { $jsonlBad++ } + } + $sids = @($sids | Select-Object -Unique) + $hosts = @($hosts | Select-Object -Unique) + } + + # Named-event checklist (detected from the human-readable shorthand lines). + function Seen([string]$pat) { [bool]($logText | Select-String -Pattern $pat -Quiet) } + $events = [ordered]@{ + "Sandbox created (lifecycle)" = Seen "(?i)ocsf:.*LIFECYCLE:" + "OS policy enforced" = Seen "(?i)ocsf:.*OS policy enforced" + "OS policy configured" = Seen "(?i)ocsf:.*OS policy configured" + "win32k lockdown applied" = Seen "(?i)ocsf:.*win32k lockdown" + "UI restrictions applied" = Seen "(?i)ocsf:.*UI restrictions" + "console reference plumbed" = Seen "(?i)ocsf:.*console reference plumbed" + "proxy configured" = Seen "(?i)ocsf:.*proxy configured" + "process launch (cmd line)" = Seen "(?i)ocsf:.*PROC:LAUNCH" + "ActivityError finding" = Seen "(?i)ocsf:.*ActivityError" + "FallbackError finding" = Seen "(?i)ocsf:.*FallbackError" + } + $classesSeen = @($classCounts.Keys | Where-Object { $classCounts[$_] -gt 0 }).Count + if ($passed) { $passed = $consumerStarted -and ($jsonlCount -gt 0) -and ($jsonlBad -eq 0) -and ($classesSeen -ge 3) } + + $verdict = if ($passed) { "PASS" } else { "FAIL" } + $classLines = foreach ($uid in @(6002, 5019, 1007, 2004)) { " [{0}] {1,-28} : {2}" -f $uid, $classNames[$uid], $classCounts[$uid] } + $eventLines = foreach ($k in $events.Keys) { " {0} {1}" -f $(if ($events[$k]) { "[x]" } else { "[ ]" }), $k } + + Step "RESULT" + $summary = @" +OpenShell MXC ETW -> OCSF audit trail +===================================== +timestamp : $stamp +machine : $env:COMPUTERNAME +user : $env:USERNAME (admin=$admin perfLogUsers=$plu) +verdict : $verdict +proxy : $(if ($proxyOn) { 'on (full event set)' } else { 'off (-NoProxy; omits SandboxProxyConfigured)' }) +wxc_exec : $WxcExecPath +backend : process_container +gateway_port : $Port +sandboxes : $SandboxCount (distinct sandbox_ids in log: $($sids.Count)) + +OCSF audit log (durable JSONL, one event per line): + file : $(if ($jsonlPath) { Split-Path $jsonlPath -Leaf } else { '(none written)' }) + events : $jsonlCount invalid-json: $jsonlBad host: $($hosts -join ',') + +OCSF classes captured: +$($classLines -join "`r`n") + +Event coverage (from the human-readable shorthand): +$($eventLines -join "`r`n") + +Files in this bundle: + transcript.txt full console transcript + gateway.log / .err.log gateway stdout/stderr (OCSF shorthand lines live here) + openshell-ocsf..log THE DELIVERABLE: durable OCSF audit trail (JSONL) + summary.txt this summary + mxc-ocsf-audit.used.toml the exact gateway config used (wxc path patched) + ocsf-audit.used.yaml the exact sandbox policy used + +What PASS means: the gateway launched sandbox(es), the in-process ETW consumer +started, decoded the Sandboxing provider, attributed each event to a sandbox_id, +mapped them to OCSF, and wrote a durable JSONL audit log spanning $classesSeen event classes - +the full Windows OCSF path end-to-end, at parity with the Linux pipeline. +"@ + Set-Content -Path (Join-Path $resultDir "summary.txt") -Value $summary -Encoding UTF8 + Write-Host $summary -ForegroundColor ($(if ($passed) { "Green" } else { "Red" })) + + try { Stop-Transcript | Out-Null } catch {} + + # Zip the bundle for easy return (defensive; never throw out of finally). + try { + $zip = Join-Path $here "results-$stamp.zip" + if (Test-Path $zip) { Remove-Item $zip -Force } + Compress-Archive -Path (Join-Path $resultDir "*") -DestinationPath $zip -Force + Write-Host "`nBUNDLE: $zip" -ForegroundColor Yellow + Write-Host "Hand that zip back for evaluation." -ForegroundColor Yellow + } catch { Write-Host "zip failed: $($_.Exception.Message)" -ForegroundColor Red } + + # Auto-push the bundle to the shared drive for pickup/analysis (skip if we + # already ran from the share, or if -ShareOut "" disables it). + if (-not [string]::IsNullOrWhiteSpace($ShareOut)) { + try { + $alreadyThere = $false + try { if ((Resolve-Path $here).Path -eq (Resolve-Path $ShareOut -ErrorAction SilentlyContinue).Path) { $alreadyThere = $true } } catch {} + if ($alreadyThere) { + Write-Host "PUSHED: results-$stamp (ran from share; already there)" -ForegroundColor Green + } elseif (Test-Path $ShareOut) { + if ($zip -and (Test-Path $zip)) { Copy-Item $zip (Join-Path $ShareOut "results-$stamp.zip") -Force } + Write-Host "PUSHED: results-$stamp.zip -> $ShareOut" -ForegroundColor Green + } else { + Write-Host "share not reachable: $ShareOut (results local only at $resultDir)" -ForegroundColor Yellow + } + } catch { Write-Host "push failed: $($_.Exception.Message)" -ForegroundColor Yellow } + } + + Write-Host "`nYour OCSF audit log:" -ForegroundColor Cyan + Write-Host " $(if ($jsonlPath) { $jsonlPath } else { '(none written - see gateway.log)' })" -ForegroundColor Green +} + +if ($passed) { exit 0 } else { exit 1 } diff --git a/crates/openshell-driver-mxc/src/etw_consumer.rs b/crates/openshell-driver-mxc/src/etw_consumer.rs index 8303ba8ea9..0503a4216b 100644 --- a/crates/openshell-driver-mxc/src/etw_consumer.rs +++ b/crates/openshell-driver-mxc/src/etw_consumer.rs @@ -1356,6 +1356,10 @@ fn emit_resolved( | "EnforceOsPolicy" | "SandboxProxyConfigured" | "SandboxConsoleReferencePlumbed" => { + // Dump the raw decoded field set for config-family events at debug so we + // can confirm the exact property names MXC emits (e.g. which key carries + // the proxy port on `SandboxProxyConfigured`). Guarded by `debug=true`. + tracing::debug!(target: "mxc_etw", pid = ev.process_id, sandbox_id = %sandbox_id, "{}", ev.summary()); let ctx = etw_ctx(sandbox_id, sandbox_name); emit_ocsf(sandbox_id, map_config_state(&ctx, ev)); } @@ -1412,11 +1416,20 @@ fn map_config_state(ctx: &SandboxContext, ev: &DecodedEtwEvent) -> OcsfEvent { "ApplyUILimits" => "MXC sandbox UI restrictions applied".to_string(), "EnforceOsPolicy" => "MXC sandbox OS policy enforced".to_string(), "SandboxConsoleReferencePlumbed" => "MXC sandbox console reference plumbed".to_string(), - // The one network-plane event the provider emits; `proxyPort=0` means no - // proxy was configured. Surface the port so the CONFIG row is self-describing. + // The one network-plane event the provider emits. Empirically the OS + // Sandboxing provider fires this event *only* when an egress proxy is + // configured for the sandbox, but it does **not** surface the port for + // MXC's URL-based proxy — `proxyPort` is always 0 (MXC redirects egress via + // a `network.proxy.localhost` policy URL, not the OS built-in proxy-port + // mechanism this field reflects). The real per-sandbox listening port is + // recorded on the host proxy's own Network Activity [4001] "Listen" event. + // So the presence of this event means a proxy WAS configured; only append a + // port on the off chance a future provider/build populates it. "SandboxProxyConfigured" => match ev.get_unquoted("proxyPort").as_deref() { - Some("0") | None => "MXC sandbox proxy configured (no proxy)".to_string(), - Some(port) => format!("MXC sandbox proxy configured (port {port})"), + Some(port) if port != "0" => { + format!("MXC sandbox proxy configured (port {port})") + } + _ => "MXC sandbox proxy configured".to_string(), }, _ => "MXC sandbox OS policy configured".to_string(), }; From 3e4648932dbe1e823ac190560a3e507c7dd97ad2 Mon Sep 17 00:00:00 2001 From: Jamie King Date: Wed, 15 Jul 2026 21:26:41 -0600 Subject: [PATCH 10/31] feat(mxc-ocsf): clearer audit report + client-safe run-ocsf-audit.ps1 Improve the ETW to OCSF audit-trail example output and make it safe to ship. Report: - Add an event-type coverage count ("N of M expected event types fired"); the denominator auto-adjusts (8 with proxy on, 7 with -NoProxy). - Split the checklist into expected event types vs anomaly findings (ActivityError/FallbackError), which are reported separately and not counted toward coverage (a clean run may emit none). - Verdict is now coverage-based (all expected types must fire) instead of the looser "at least 3 OCSF classes". - Call out the absolute path to the durable OCSF JSONL log prominently. Client-safety: - Default -ShareOut to empty (no auto-copy); pass -ShareOut a UNC path to opt in. Removes a hardcoded internal share path from a published example. - Drop internal-team wording ("Hand that zip back for evaluation", "BUNDLE:") in favor of neutral "Results bundle:". - Update README-ocsf-audit.txt to match the opt-in -ShareOut behavior. Verified on both MXC boxes: 7F203-MXC-001 (base-container) -> PASS, 8 of 8 event types, 26 OCSF events across 4 classes; 7F203-MXC-003 (AppContainer fallback) -> reduced set as expected, clean output. Signed-off-by: Akber Raza --- .../examples/README-ocsf-audit.txt | 14 +-- .../examples/run-ocsf-audit.ps1 | 89 +++++++++++-------- 2 files changed, 62 insertions(+), 41 deletions(-) diff --git a/crates/openshell-driver-mxc/examples/README-ocsf-audit.txt b/crates/openshell-driver-mxc/examples/README-ocsf-audit.txt index 26d4af2ef3..ba960e42cf 100644 --- a/crates/openshell-driver-mxc/examples/README-ocsf-audit.txt +++ b/crates/openshell-driver-mxc/examples/README-ocsf-audit.txt @@ -35,12 +35,13 @@ HOW TO RUN ... -File .\run-ocsf-audit.ps1 -WxcExecPath "D:\path\to\wxc-exec.exe" WHAT YOU GET BACK - The script prints PASS/FAIL + a class/event breakdown and creates: + The script prints PASS/FAIL + an event-type coverage count and class breakdown, + points you at the OCSF audit log, and creates: results-.zip - Hand that zip back. It contains the OCSF audit log (openshell-ocsf..log), - the full transcript, the gateway logs (with the human-readable OCSF shorthand), - a summary, and the exact config + policy used. The bundle is also auto-copied - to the shared drive for pickup (pass -ShareOut "" to disable that). + It contains the OCSF audit log (openshell-ocsf..log), the full transcript, + the gateway logs (with the human-readable OCSF shorthand), a summary, and the + exact config + policy used. To auto-copy the bundle to a shared location, pass + -ShareOut '\\server\share' (off by default; results stay local otherwise). FILES IN THIS PACKAGE openshell-gateway.exe the gateway (self-contained; needs only VC++ runtime) @@ -57,7 +58,8 @@ USEFUL OPTIONS SandboxProxyConfigured config event; everything else is still produced. (Default is proxy ON for the full set.) -WxcExecPath Path to wxc-exec.exe on this box. - -ShareOut "" Disable the auto-copy of the results bundle to the share. + -ShareOut Copy the results bundle to a shared location + (e.g. \\server\share). Off by default (results stay local). -KeepRunning Leave the gateway running afterward for inspection. NOTES diff --git a/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 index 8c723a84e6..e36f995f4e 100644 --- a/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 +++ b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 @@ -26,7 +26,9 @@ # By default the per-sandbox egress proxy is ON so the full event set (including # SandboxProxyConfigured) is produced. Pass -NoProxy to omit only that one event. # -# Hand the produced results-*.zip back for evaluation. +# The deliverable is the OCSF audit log (openshell-ocsf..log) inside the +# results-*.zip the script produces. Pass -ShareOut '\\server\share' to also copy +# the bundle to a shared location (off by default). [CmdletBinding()] param( @@ -43,9 +45,9 @@ param( [string] $GatewayName = "openshell-mxc-ocsf", # Internal driver ETW session name (used to clean up a leaked session). [string] $SessionName = "OpenShell-MXC-ETW", - # Shared drive the results bundle is auto-copied to for pickup/analysis. - # Set to "" to disable the push. - [string] $ShareOut = "\\nvsw-dump\users\jamiek\prashant", + # Optional: copy the results bundle to this path (e.g. a shared drive) for + # pickup. Empty by default (no copy); pass -ShareOut '\\server\share' to enable. + [string] $ShareOut = "", # Leave the gateway running afterward (for inspection). [switch] $KeepRunning ) @@ -255,26 +257,40 @@ finally { $hosts = @($hosts | Select-Object -Unique) } - # Named-event checklist (detected from the human-readable shorthand lines). + # Event-type coverage (detected from the human-readable shorthand lines). function Seen([string]$pat) { [bool]($logText | Select-String -Pattern $pat -Quiet) } - $events = [ordered]@{ - "Sandbox created (lifecycle)" = Seen "(?i)ocsf:.*LIFECYCLE:" - "OS policy enforced" = Seen "(?i)ocsf:.*OS policy enforced" - "OS policy configured" = Seen "(?i)ocsf:.*OS policy configured" - "win32k lockdown applied" = Seen "(?i)ocsf:.*win32k lockdown" - "UI restrictions applied" = Seen "(?i)ocsf:.*UI restrictions" - "console reference plumbed" = Seen "(?i)ocsf:.*console reference plumbed" - "proxy configured" = Seen "(?i)ocsf:.*proxy configured" - "process launch (cmd line)" = Seen "(?i)ocsf:.*PROC:LAUNCH" - "ActivityError finding" = Seen "(?i)ocsf:.*ActivityError" - "FallbackError finding" = Seen "(?i)ocsf:.*FallbackError" + + # Expected happy-path ETW->OCSF event types for THIS run. The egress-proxy + # event only fires when the proxy is enabled, so it only counts toward the + # expected total when -NoProxy was NOT passed. + $coreEvents = [ordered]@{ + "sandbox lifecycle (start)" = Seen "(?i)ocsf:.*LIFECYCLE:" + "OS policy enforced" = Seen "(?i)ocsf:.*OS policy enforced" + "OS policy configured" = Seen "(?i)ocsf:.*OS policy configured" + "win32k lockdown applied" = Seen "(?i)ocsf:.*win32k lockdown" + "UI restrictions applied" = Seen "(?i)ocsf:.*UI restrictions" + "console reference plumbed" = Seen "(?i)ocsf:.*console reference plumbed" + "process launch (command line)" = Seen "(?i)ocsf:.*PROC:LAUNCH" + } + if ($proxyOn) { $coreEvents["egress proxy configured"] = Seen "(?i)ocsf:.*proxy configured" } + + # Findings are anomaly / fallback signals - reported separately, NOT part of + # the expected-coverage denominator (a clean run may emit none). + $findingEvents = [ordered]@{ + "ActivityError finding" = Seen "(?i)ocsf:.*ActivityError" + "FallbackError finding" = Seen "(?i)ocsf:.*FallbackError" } - $classesSeen = @($classCounts.Keys | Where-Object { $classCounts[$_] -gt 0 }).Count - if ($passed) { $passed = $consumerStarted -and ($jsonlCount -gt 0) -and ($jsonlBad -eq 0) -and ($classesSeen -ge 3) } - $verdict = if ($passed) { "PASS" } else { "FAIL" } - $classLines = foreach ($uid in @(6002, 5019, 1007, 2004)) { " [{0}] {1,-28} : {2}" -f $uid, $classNames[$uid], $classCounts[$uid] } - $eventLines = foreach ($k in $events.Keys) { " {0} {1}" -f $(if ($events[$k]) { "[x]" } else { "[ ]" }), $k } + $coreExpected = $coreEvents.Count + $coreObserved = @($coreEvents.Values | Where-Object { $_ }).Count + $findingsObserved = @($findingEvents.Values | Where-Object { $_ }).Count + $classesSeen = @($classCounts.Keys | Where-Object { $classCounts[$_] -gt 0 }).Count + if ($passed) { $passed = $consumerStarted -and ($jsonlCount -gt 0) -and ($jsonlBad -eq 0) -and ($coreObserved -eq $coreExpected) } + + $verdict = if ($passed) { "PASS" } else { "FAIL" } + $classLines = foreach ($uid in @(6002, 5019, 1007, 2004)) { " [{0}] {1,-28} : {2}" -f $uid, $classNames[$uid], $classCounts[$uid] } + $coreLines = foreach ($k in $coreEvents.Keys) { " {0} {1}" -f $(if ($coreEvents[$k]) { "[x]" } else { "[ ]" }), $k } + $findingLines = foreach ($k in $findingEvents.Keys) { " {0} {1}" -f $(if ($findingEvents[$k]) { "[x]" } else { "[ ]" }), $k } Step "RESULT" $summary = @" @@ -284,34 +300,38 @@ timestamp : $stamp machine : $env:COMPUTERNAME user : $env:USERNAME (admin=$admin perfLogUsers=$plu) verdict : $verdict -proxy : $(if ($proxyOn) { 'on (full event set)' } else { 'off (-NoProxy; omits SandboxProxyConfigured)' }) +event coverage : $coreObserved of $coreExpected expected event types fired (+ $findingsObserved anomaly finding(s)) +proxy : $(if ($proxyOn) { 'on (full event set)' } else { 'off (-NoProxy; omits egress proxy event)' }) wxc_exec : $WxcExecPath backend : process_container gateway_port : $Port sandboxes : $SandboxCount (distinct sandbox_ids in log: $($sids.Count)) -OCSF audit log (durable JSONL, one event per line): - file : $(if ($jsonlPath) { Split-Path $jsonlPath -Leaf } else { '(none written)' }) - events : $jsonlCount invalid-json: $jsonlBad host: $($hosts -join ',') +Event-type coverage - $coreObserved of $coreExpected expected event types fired: +$($coreLines -join "`r`n") + +Anomaly findings emitted (not counted toward coverage; a clean run may emit none): $findingsObserved +$($findingLines -join "`r`n") -OCSF classes captured: +OCSF events written : $jsonlCount total ($jsonlBad invalid-json) across $classesSeen OCSF class(es) $($classLines -join "`r`n") -Event coverage (from the human-readable shorthand): -$($eventLines -join "`r`n") +>> YOUR OCSF AUDIT LOG (the deliverable - durable JSONL, one OCSF event per line): + $(if ($jsonlPath) { $jsonlPath } else { '(none written - see gateway.log)' }) -Files in this bundle: - transcript.txt full console transcript - gateway.log / .err.log gateway stdout/stderr (OCSF shorthand lines live here) +Files in this bundle ($resultDir): openshell-ocsf..log THE DELIVERABLE: durable OCSF audit trail (JSONL) summary.txt this summary + transcript.txt full console transcript + gateway.log / .err.log gateway stdout/stderr (OCSF shorthand lines live here) mxc-ocsf-audit.used.toml the exact gateway config used (wxc path patched) ocsf-audit.used.yaml the exact sandbox policy used What PASS means: the gateway launched sandbox(es), the in-process ETW consumer started, decoded the Sandboxing provider, attributed each event to a sandbox_id, -mapped them to OCSF, and wrote a durable JSONL audit log spanning $classesSeen event classes - -the full Windows OCSF path end-to-end, at parity with the Linux pipeline. +mapped them to OCSF, and wrote a durable JSONL audit log covering all $coreExpected +expected event types across $classesSeen OCSF class(es) - the full Windows OCSF path +end-to-end, at parity with the Linux pipeline. "@ Set-Content -Path (Join-Path $resultDir "summary.txt") -Value $summary -Encoding UTF8 Write-Host $summary -ForegroundColor ($(if ($passed) { "Green" } else { "Red" })) @@ -323,8 +343,7 @@ the full Windows OCSF path end-to-end, at parity with the Linux pipeline. $zip = Join-Path $here "results-$stamp.zip" if (Test-Path $zip) { Remove-Item $zip -Force } Compress-Archive -Path (Join-Path $resultDir "*") -DestinationPath $zip -Force - Write-Host "`nBUNDLE: $zip" -ForegroundColor Yellow - Write-Host "Hand that zip back for evaluation." -ForegroundColor Yellow + Write-Host "`nResults bundle: $zip" -ForegroundColor Yellow } catch { Write-Host "zip failed: $($_.Exception.Message)" -ForegroundColor Red } # Auto-push the bundle to the shared drive for pickup/analysis (skip if we From 82575c51638b766f412ccb9bb080621214cfc123 Mon Sep 17 00:00:00 2001 From: Akber Raza Date: Wed, 1 Jul 2026 12:02:57 -0500 Subject: [PATCH 11/31] Implement Windows host proxy integration and update dependencies for OpenShell --- Cargo.lock | 2 + crates/openshell-driver-mxc/Cargo.toml | 4 +- crates/openshell-driver-mxc/README.md | 30 ++- crates/openshell-driver-mxc/src/driver.rs | 115 +++++++++- crates/openshell-driver-mxc/src/mxc.rs | 4 + .../openshell-supervisor-network/Cargo.toml | 2 +- .../openshell-supervisor-network/src/host.rs | 122 +++++++++++ .../src/identity.rs | 38 +++- .../openshell-supervisor-network/src/lib.rs | 1 + .../openshell-supervisor-network/src/proxy.rs | 198 +++++++++++++----- .../openshell-supervisor-network/src/run.rs | 29 ++- 11 files changed, 465 insertions(+), 80 deletions(-) create mode 100644 crates/openshell-supervisor-network/src/host.rs diff --git a/Cargo.lock b/Cargo.lock index ba10b9f01a..3771bf0186 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4031,6 +4031,7 @@ dependencies = [ "openshell-core", "openshell-ocsf", "openshell-policy", + "openshell-supervisor-network", "serde", "serde_json", "serde_yml", @@ -5632,6 +5633,7 @@ dependencies = [ "rand 0.9.4", "serde", "serde_json", + "serde_yaml", "spin", "thiserror 2.0.18", ] diff --git a/crates/openshell-driver-mxc/Cargo.toml b/crates/openshell-driver-mxc/Cargo.toml index b05c7e82e1..9da4819cbe 100644 --- a/crates/openshell-driver-mxc/Cargo.toml +++ b/crates/openshell-driver-mxc/Cargo.toml @@ -30,9 +30,9 @@ tracing = { workspace = true } thiserror = { workspace = true } uuid = { workspace = true } -# ETW/TDH real-time consumer (Plane A audit). Windows-only so the Linux/WSL -# build stays an empty stub. +# ETW/TDH real-time consumer and host CONNECT proxy integration. [target.'cfg(target_os = "windows")'.dependencies] +openshell-supervisor-network = { path = "../openshell-supervisor-network" } windows = { workspace = true } [dev-dependencies] diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 6496dfe286..436c6255fc 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -13,13 +13,16 @@ 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. | +| 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`; the driver starts a per-sandbox host CONNECT proxy from the trimmed network policy | HTTPS MITM trust bootstrap and gateway event-bus wiring follow-on | +| 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 | gateway interactive-exec surgery (follow-on) | +| 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 filesystem enforcement proof has two paths: @@ -66,7 +69,14 @@ no isolation session needed), set `OPENSHELL_MXC_MOCK_WXC=1`. 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. +When `egress_proxy` is enabled, `EmbeddedPolicyMapper` uses `split_policy` +instead: MXC receives filesystem grants plus a loopback `network.proxy` +redirect, and the driver starts a host CONNECT proxy from the trimmed +network-only `SandboxPolicy`. The proxy uses the configured agent command as +the static sandbox process identity because MXC does not expose Linux-style +procfs socket ownership. 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. 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. @@ -112,7 +122,7 @@ velocity keys not enabled, isolation_session absent). ## Deferred work -- **Interactive exec/connect/forward** → `adapt-openshell-gateway-windows` -- **Governed egress** remains fail-closed until an enforcing proxy is implemented and bound to sandbox lifecycle. +- **Interactive exec/connect/forward** — gateway interactive-exec surgery (follow-on) +- **Governed egress polish** — HTTPS MITM trust bootstrap, gateway denial/activity bus wiring, and per-sandbox port allocation - **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 index b5334fda70..fb574483a1 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -17,6 +17,8 @@ use openshell_core::proto::compute::v1::{ use openshell_core::proto_struct::struct_to_json_value; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::net::SocketAddr; +use std::path::PathBuf; use std::pin::Pin; use std::sync::Arc; use tokio::sync::{Mutex, broadcast, mpsc, watch}; @@ -65,6 +67,13 @@ pub struct MxcComputeConfig { /// MXC `configurationId` for isolation session. Default: `"composable"`. /// Never use `"small"` (known OS bug). pub default_configuration_id: String, + /// Enable Pattern-C governed egress. When true, MXC receives filesystem + /// grants plus a `network.proxy` redirect and the host CONNECT proxy + /// receives the 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 added by the follow-up commit. + pub egress_proxy_addr: String, /// Enable `--debug` flag on `wxc-exec` invocations. pub debug: bool, @@ -82,6 +91,8 @@ impl Default for MxcComputeConfig { pc_least_privilege: false, pc_capabilities: Vec::new(), default_configuration_id: crate::mxc::DEFAULT_CONFIGURATION_ID.into(), + egress_proxy: false, + egress_proxy_addr: String::new(), debug: false, etw_audit: false, @@ -118,6 +129,9 @@ struct SandboxEntry { lifecycle_gate: Arc>, monitor_cancel: Option>, monitor_task: Option>, + trimmed_policy: Option, + proxy_addr: Option, + host_proxy: Option, } impl std::fmt::Debug for SandboxEntry { @@ -250,6 +264,35 @@ fn sandbox_environment(sandbox: &DriverSandbox) -> Vec { environment } +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", + )); + } + let addr = raw.parse::().map_err(|error| { + tonic::Status::invalid_argument(format!( + "mxc egress_proxy_addr must be an IP:PORT socket address: {error}" + )) + })?; + 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 because MXC 0.6.0-alpha can encode only a localhost proxy port (got {})", + addr.ip() + ))); + } + Ok(Some(addr)) +} + fn encode_windows_command_line(args: &[String]) -> String { args.iter() .map(|arg| quote_windows_argument(arg)) @@ -283,6 +326,14 @@ fn quote_windows_argument(arg: &str) -> String { quoted.push('"'); quoted } +fn host_proxy_binary_path(config: &MxcSandboxConfig) -> PathBuf { + config + .command + .first() + .filter(|command| !command.trim().is_empty()) + .map_or_else(|| PathBuf::from("mxc-agent"), PathBuf::from) +} + impl MxcComputeBackend { pub fn new(config: MxcComputeConfig) -> Self { let invoker = WxcExecInvoker::new(&config.wxc_exec_path, config.debug); @@ -389,6 +440,7 @@ impl MxcComputeBackend { let policy = self.pending_policies.lock().await.remove(&sandbox_id); self.validate_sandbox_create(sandbox)?; let sandbox_config = sandbox_config(sandbox)?; + let egress_addr = configured_egress_addr(&self.config)?; // Policy translation is deterministic and side-effect free. Do it before // inserting the registry entry or launching MXC so invalid requests fail @@ -399,7 +451,7 @@ impl MxcComputeBackend { policy.as_ref(), &MapCtx { sandbox_id: sandbox_id.clone(), - egress: None, + egress: egress_addr, }, ) .map_err(|error| tonic::Status::invalid_argument(error.to_string()))?; @@ -450,6 +502,9 @@ impl MxcComputeBackend { lifecycle_gate, monitor_cancel: None, monitor_task: None, + trimmed_policy: None, + proxy_addr: None, + host_proxy: None, }, ); } @@ -522,6 +577,7 @@ impl MxcComputeBackend { let mut registry = self.registry.lock().await; if let Some(entry) = registry.get_mut(&sandbox_id) { entry.isolation_stopped = isolation_stopped; + entry.host_proxy = None; entry.phase_state = PhaseState::Stopped; entry.sandbox = make_sandbox_with_condition( &entry.sandbox, @@ -670,6 +726,60 @@ async fn run_lifecycle( ) { let sandbox_id = sandbox.id.clone(); let sandbox_name = sandbox.name.clone(); + let trimmed_policy = mapped.trimmed_policy.clone(); + let proxy_addr = mapped.proxy_addr; + let host_proxy = if !invoker.is_mock() + && let (Some(addr), Some(proxy_policy)) = (proxy_addr, trimmed_policy.clone()) + { + match openshell_supervisor_network::host::start_host_proxy( + openshell_supervisor_network::host::HostProxyConfig { + bind_addr: addr, + policy: proxy_policy, + binary_path: host_proxy_binary_path(&sandbox_config), + sandbox_id: Some(sandbox_id.clone()), + sandbox_name: Some(sandbox_name.clone()), + openshell_endpoint: None, + inference_routes: None, + provider_credentials: None, + agent_proposals: openshell_core::proposals::AgentProposals::default(), + denial_tx: None, + activity_tx: None, + }, + ) + .await + { + Ok(handle) => Some(handle), + Err(error) => { + set_failed( + ®istry, + &watch_tx, + &sandbox, + &sandbox_id, + &format!("failed to start MXC host egress proxy at {addr}: {error}"), + ) + .await; + return; + } + } + } else { + None + }; + if let Some(addr) = proxy_addr { + { + let mut registry = registry.lock().await; + if let Some(entry) = registry.get_mut(&sandbox_id) { + entry.trimmed_policy = trimmed_policy; + entry.proxy_addr = Some(addr); + entry.host_proxy = host_proxy; + } + } + let _ = watch_tx.send(platform_event( + sandbox_id.clone(), + "EgressRedirect", + format!("MXC egress redirected to OpenShell host CONNECT proxy at {addr}"), + )); + } + let filesystem = MxcFilesystem { readwrite_paths: mapped.readwrite_paths, readonly_paths: mapped.readonly_paths, @@ -858,6 +968,7 @@ async fn monitor_exec( ); let mut registry = registry.lock().await; if let Some(entry) = registry.get_mut(&sandbox_id) { + entry.host_proxy = None; entry.sandbox = done.clone(); entry.phase_state = PhaseState::Running; } @@ -885,6 +996,7 @@ async fn monitor_exec( ); let mut registry = registry.lock().await; if let Some(entry) = registry.get_mut(&sandbox_id) { + entry.host_proxy = None; entry.sandbox = failed.clone(); entry.phase_state = PhaseState::Failed(format!("exit code {code}")); } @@ -917,6 +1029,7 @@ async fn set_failed( ); let mut reg = registry.lock().await; if let Some(entry) = reg.get_mut(sandbox_id) { + entry.host_proxy = None; entry.sandbox = failed.clone(); entry.phase_state = PhaseState::Failed(message.to_string()); } diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs index 32d7006d90..9ca165df7b 100644 --- a/crates/openshell-driver-mxc/src/mxc.rs +++ b/crates/openshell-driver-mxc/src/mxc.rs @@ -302,6 +302,10 @@ impl WxcExecInvoker { } } + pub(crate) const fn is_mock(&self) -> bool { + self.mock + } + /// 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`). diff --git a/crates/openshell-supervisor-network/Cargo.toml b/crates/openshell-supervisor-network/Cargo.toml index 34d9c32a47..d090550dc9 100644 --- a/crates/openshell-supervisor-network/Cargo.toml +++ b/crates/openshell-supervisor-network/Cargo.toml @@ -32,7 +32,7 @@ ipnet = "2" miette = { workspace = true } prost-types = { workspace = true } rcgen = { workspace = true } -regorus = { version = "0.9", default-features = false, features = ["std", "arc", "glob"] } +regorus = { version = "0.9", default-features = false, features = ["std", "arc", "glob", "yaml"] } reqwest = { workspace = true } rustls = { workspace = true } rustls-native-certs = { workspace = true } diff --git a/crates/openshell-supervisor-network/src/host.rs b/crates/openshell-supervisor-network/src/host.rs new file mode 100644 index 0000000000..8d96f49583 --- /dev/null +++ b/crates/openshell-supervisor-network/src/host.rs @@ -0,0 +1,122 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Host-side proxy startup for compute drivers that do not run the Linux +//! in-sandbox supervisor. +//! +//! MXC uses this on Windows: MXC's `network.proxy` redirects sandbox egress to a +//! per-sandbox loopback listener in the gateway process, and this module starts +//! the existing OpenShell CONNECT proxy against the trimmed network-only +//! `SandboxPolicy`. + +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::Arc; + +use miette::Result; +use openshell_core::activity::ActivitySender; +use openshell_core::denial::DenialEvent; +use openshell_core::policy::ProxyPolicy; +use openshell_core::proposals::AgentProposals; +use openshell_core::proto::SandboxPolicy as ProtoSandboxPolicy; +use openshell_core::provider_credentials::ProviderCredentialState; +use tokio::sync::mpsc::UnboundedSender; + +use crate::opa::OpaEngine; +use crate::policy_local::PolicyLocalContext; +use crate::proxy::{ProxyHandle, ProxyIdentityMode}; + +/// Configuration for a host-side OpenShell CONNECT proxy. +pub struct HostProxyConfig { + /// Exact socket the compute driver will redirect sandbox egress to. + pub bind_addr: SocketAddr, + /// Network-only policy produced by the compute driver's policy split. + pub policy: ProtoSandboxPolicy, + /// Static process identity used when the platform cannot recover the + /// socket-owning sandbox process. Policy binaries must match this path for + /// L4/L7 allow rules to pass. + pub binary_path: PathBuf, + pub sandbox_id: Option, + pub sandbox_name: Option, + pub openshell_endpoint: Option, + pub inference_routes: Option, + pub provider_credentials: Option, + /// Shared feature state for the policy.local agent proposal surface. + pub agent_proposals: AgentProposals, + pub denial_tx: Option>, + pub activity_tx: Option, +} + +/// RAII handle for a host-side proxy. Dropping it aborts the proxy accept loop. +pub struct HostProxyHandle { + proxy: ProxyHandle, + pub policy_local_ctx: Arc, +} + +impl HostProxyHandle { + #[must_use] + pub const fn http_addr(&self) -> Option { + self.proxy.http_addr() + } +} + +/// Start a host-side proxy for one sandbox. +/// +/// Linux supervisor mode should continue to use `run::run_networking`; this API +/// is for host-side compute-driver integrations such as Windows MXC. +pub async fn start_host_proxy(config: HostProxyConfig) -> Result { + if !config.bind_addr.ip().is_loopback() { + return Err(miette::miette!( + "host proxy bind address must be loopback-only: {}", + config.bind_addr + )); + } + + let engine = Arc::new(OpaEngine::from_proto(&config.policy)?); + let (_workspace_tx, workspace_rx) = tokio::sync::watch::channel(String::new()); + let policy_local_ctx = Arc::new(PolicyLocalContext::new( + Some(config.policy.clone()), + config.openshell_endpoint.clone(), + config + .sandbox_name + .clone() + .or_else(|| config.sandbox_id.clone()), + config.agent_proposals, + workspace_rx, + )); + let inference_ctx = crate::inference_routes::build_inference_context( + config.sandbox_id.as_deref(), + config.openshell_endpoint.as_deref(), + config.inference_routes.as_deref(), + ) + .await?; + + let (_ready_tx, ready_rx) = tokio::sync::watch::channel(true); + let proxy_policy = ProxyPolicy { + http_addr: Some(config.bind_addr), + }; + let upstream_proxy_args = crate::upstream_proxy::UpstreamProxyArgs::default(); + let proxy = ProxyHandle::start_with_bind_addr( + &proxy_policy, + Some(config.bind_addr), + engine, + Arc::new(ProxyIdentityMode::static_binary(config.binary_path)), + // Host mode does not install a CA into the sandbox yet; L4 policy and + // plaintext/forward-proxy L7 paths are active, while HTTPS MITM is a + // follow-up once MXC has a trust-bootstrap story. + None, + inference_ctx, + config.provider_credentials, + Some(policy_local_ctx.clone()), + config.denial_tx, + config.activity_tx, + ready_rx, + &upstream_proxy_args, + ) + .await?; + + Ok(HostProxyHandle { + proxy, + policy_local_ctx, + }) +} diff --git a/crates/openshell-supervisor-network/src/identity.rs b/crates/openshell-supervisor-network/src/identity.rs index 5e89c35031..e81a564224 100644 --- a/crates/openshell-supervisor-network/src/identity.rs +++ b/crates/openshell-supervisor-network/src/identity.rs @@ -33,12 +33,33 @@ struct FileFingerprint { impl FileFingerprint { fn from_metadata(metadata: &Metadata) -> Self { + #[cfg(unix)] + let (mtime_sec, mtime_nsec, ctime_sec, ctime_nsec) = ( + metadata.mtime(), + metadata.mtime_nsec(), + metadata.ctime(), + metadata.ctime_nsec(), + ); + #[cfg(not(unix))] + let (mtime_sec, mtime_nsec, ctime_sec, ctime_nsec) = { + let (mtime_sec, mtime_nsec) = metadata + .modified() + .ok() + .and_then(system_time_parts) + .unwrap_or_default(); + let (ctime_sec, ctime_nsec) = metadata + .created() + .ok() + .and_then(system_time_parts) + .unwrap_or_default(); + (mtime_sec, mtime_nsec, ctime_sec, ctime_nsec) + }; Self { len: metadata.len(), - mtime_sec: metadata.mtime(), - mtime_nsec: metadata.mtime_nsec(), - ctime_sec: metadata.ctime(), - ctime_nsec: metadata.ctime_nsec(), + mtime_sec, + mtime_nsec, + ctime_sec, + ctime_nsec, #[cfg(unix)] dev: metadata.dev(), #[cfg(unix)] @@ -47,6 +68,15 @@ impl FileFingerprint { } } +#[cfg(not(unix))] +fn system_time_parts(time: std::time::SystemTime) -> Option<(i64, i64)> { + let duration = time.duration_since(std::time::UNIX_EPOCH).ok()?; + Some(( + duration.as_secs() as i64, + i64::from(duration.subsec_nanos()), + )) +} + impl PartialEq for FileFingerprint { fn eq(&self, other: &Self) -> bool { self.len == other.len diff --git a/crates/openshell-supervisor-network/src/lib.rs b/crates/openshell-supervisor-network/src/lib.rs index 4fec48b300..a69e0a78ef 100644 --- a/crates/openshell-supervisor-network/src/lib.rs +++ b/crates/openshell-supervisor-network/src/lib.rs @@ -8,6 +8,7 @@ //! owned by the orchestrator; this crate produces denials but does not //! aggregate them. +pub mod host; pub mod identity; pub mod inference_routes; pub mod l7; diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index dc2736a4ea..5b5afdc84e 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -7,6 +7,7 @@ pub(crate) mod destination; mod egress; mod relay; +#[cfg(target_os = "linux")] use crate::identity::BinaryIdentityCache; use crate::l7::tls::ProxyTlsState; use crate::opa::{NetworkAction, OpaEngine, PolicyGenerationGuard}; @@ -34,7 +35,8 @@ use std::mem::size_of; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; use std::sync::Arc; -use std::sync::atomic::{AtomicU32, Ordering}; +#[cfg(target_os = "linux")] +use std::sync::atomic::AtomicU32; use tokio::io::{ AsyncRead as TokioAsyncRead, AsyncReadExt, AsyncWrite as TokioAsyncWrite, AsyncWriteExt, }; @@ -93,10 +95,10 @@ fn emit_credential_endpoint_mismatch(host: &str, port: u16, policy_name: &str) { ocsf_emit!(finding); } -/// Hostnames injected by compute drivers as `/etc/hosts` aliases for the host +/// Hostnames injected by compute drivers as hosts-file aliases for the host /// machine. Traffic to these names is eligible for the trusted-gateway SSRF /// exemption when the resolved IP matches the driver-injected value read from -/// `/etc/hosts` at proxy startup. +/// the platform hosts file at proxy startup. const HOST_GATEWAY_ALIASES: &[&str] = &[ "host.openshell.internal", "host.containers.internal", @@ -235,6 +237,55 @@ pub struct ProxyHandle { exited_rx: Option>, } +#[derive(Clone)] +pub(crate) enum ProxyIdentityMode { + /// Linux supervisor mode: bind a CONNECT request to the process that owns + /// the redirected TCP socket via procfs. + #[cfg(target_os = "linux")] + Procfs { + identity_cache: Arc, + entrypoint_pid: Arc, + }, + /// Host-side mode for platforms where procfs socket ownership is + /// unavailable. MXC uses this on Windows: every connection redirected to + /// the per-sandbox listener is evaluated as the configured sandbox agent + /// identity. + Static { + binary_path: PathBuf, + binary_sha256: String, + }, +} + +impl ProxyIdentityMode { + #[cfg(target_os = "linux")] + pub(crate) fn procfs( + identity_cache: Arc, + entrypoint_pid: Arc, + ) -> Self { + Self::Procfs { + identity_cache, + entrypoint_pid, + } + } + + pub(crate) fn static_binary(path: impl Into) -> Self { + Self::Static { + binary_path: path.into(), + binary_sha256: "openshell-host-proxy-static-identity".to_string(), + } + } + + fn entrypoint_pid(&self) -> u32 { + match self { + #[cfg(target_os = "linux")] + Self::Procfs { entrypoint_pid, .. } => { + entrypoint_pid.load(std::sync::atomic::Ordering::Acquire) + } + Self::Static { .. } => 0, + } + } +} + impl ProxyHandle { /// Start the proxy with OPA engine for policy evaluation. /// @@ -245,8 +296,7 @@ impl ProxyHandle { policy: &ProxyPolicy, bind_addr: Option, opa_engine: Arc, - identity_cache: Arc, - entrypoint_pid: Arc, + identity_mode: Arc, tls_state: Option>, inference_ctx: Option>, provider_credentials: Option, @@ -283,14 +333,14 @@ impl ProxyHandle { ocsf_emit!(event); } - // Detect the trusted host gateway IP from /etc/hosts before user code - // runs. This is read once at startup so later /etc/hosts modifications - // by sandbox workloads cannot influence the stored value. + // Detect the trusted host gateway IP from the platform hosts file + // before user code runs. This is read once at startup so later hosts + // file modifications by sandbox workloads cannot influence it. let trusted_host_gateway: Arc> = Arc::new(detect_trusted_host_gateway()); if let Some(ref ip) = *trusted_host_gateway { tracing::info!( %ip, - "Trusted host gateway detected from /etc/hosts; \ + "Trusted host gateway detected from platform hosts file; \ host-gateway aliases exempt from SSRF always-blocked check" ); } @@ -377,8 +427,7 @@ impl ProxyHandle { consecutive_unknown_errors = 0; set_tcp_nodelay_best_effort(&stream); let opa = opa_engine.clone(); - let cache = identity_cache.clone(); - let spid = entrypoint_pid.clone(); + let identity = identity_mode.clone(); let tls = tls_state.clone(); let inf = inference_ctx.clone(); let policy_local = policy_local_ctx.clone(); @@ -401,8 +450,7 @@ impl ProxyHandle { if let Err(err) = handle_tcp_connection( stream, opa, - cache, - spid, + identity, tls, inf, policy_local, @@ -625,7 +673,7 @@ async fn handle_transparent_tcp_connection( let cache = identity_cache.clone(); let pid = entrypoint_pid.clone(); let decision = tokio::task::spawn_blocking(move || { - authorize_egress_intent(connection, &engine, &cache, &pid, intent) + authorize_egress_intent_procfs(connection, &engine, &cache, &pid, intent) }) .await .map_err(|error| miette::miette!("identity resolution task panicked: {error}"))?; @@ -1685,8 +1733,7 @@ async fn deny_forward_destination( async fn handle_tcp_connection( mut client: TcpStream, opa_engine: Arc, - identity_cache: Arc, - entrypoint_pid: Arc, + identity_mode: Arc, tls_state: Option>, inference_ctx: Option>, policy_local_ctx: Option>, @@ -1758,8 +1805,7 @@ async fn handle_tcp_connection( used, &mut client, opa_engine, - identity_cache, - entrypoint_pid, + identity_mode, policy_local_ctx, agent_proposals, trusted_host_gateway, @@ -1811,11 +1857,10 @@ async fn handle_tcp_connection( // Wrapped in spawn_blocking because identity resolution does heavy sync I/O: // /proc scanning + SHA256 hashing of binaries (e.g. node at 124MB). let opa_clone = opa_engine.clone(); - let cache_clone = identity_cache.clone(); - let pid_clone = entrypoint_pid.clone(); + let identity_clone = identity_mode.clone(); let intent = EgressIntent::connect(host_lc.clone(), port); let mut decision = tokio::task::spawn_blocking(move || { - authorize_egress_intent(connection, &opa_clone, &cache_clone, &pid_clone, intent) + authorize_egress_intent(connection, &opa_clone, &identity_clone, intent) }) .await .map_err(|e| miette::miette!("identity resolution task panicked: {e}"))?; @@ -1933,7 +1978,7 @@ async fn handle_tcp_connection( let effective_tls_skip = decision.endpoint.tls_mode == crate::l7::TlsMode::Skip; let credential_guard = query_endpoint_credential_guard(&opa_engine, &decision, &host_lc, port)?; - let sandbox_entrypoint_pid = entrypoint_pid.load(Ordering::Acquire); + let sandbox_entrypoint_pid = identity_mode.entrypoint_pid(); match hydrate_destination_plan(&mut decision, *trusted_host_gateway) { Ok(()) => {} @@ -2607,7 +2652,7 @@ fn resolve_process_identity( /// Evaluate OPA policy for a TCP connection with identity binding via /proc/net/tcp. #[cfg(target_os = "linux")] -fn authorize_egress_intent( +fn authorize_egress_intent_procfs( connection: crate::procfs::WorkloadProxyTcpConnection, engine: &OpaEngine, identity_cache: &BinaryIdentityCache, @@ -2780,33 +2825,67 @@ fn evaluate_endpoint_only_opa(engine: &OpaEngine, intent: EgressIntent) -> Egres } } -/// Non-Linux stub: OPA identity binding requires /proc. -#[cfg(not(target_os = "linux"))] fn authorize_egress_intent( _connection: crate::procfs::WorkloadProxyTcpConnection, engine: &OpaEngine, - _identity_cache: &BinaryIdentityCache, - _entrypoint_pid: &AtomicU32, + identity_mode: &ProxyIdentityMode, intent: EgressIntent, ) -> EgressDecision { if !crate::opa::network_binary_identity_required() { return evaluate_endpoint_only_opa(engine, intent); } - EgressDecision { - intent, - action: NetworkAction::Deny { - reason: "identity binding unavailable on this platform".into(), - }, - policy_generation: engine.current_generation(), - identity: ProcessIdentityEvidence::Unavailable( - IdentityUnavailableReason::UnsupportedPlatform, + match identity_mode { + #[cfg(target_os = "linux")] + ProxyIdentityMode::Procfs { + identity_cache, + entrypoint_pid, + } => authorize_egress_intent_procfs( + _connection, + engine, + identity_cache, + entrypoint_pid, + intent, ), - endpoint: EndpointDecision::default(), - binary: None, - binary_pid: None, - ancestors: vec![], - cmdline_paths: vec![], + ProxyIdentityMode::Static { + binary_path, + binary_sha256, + } => { + let input = crate::opa::NetworkInput { + host: intent.destination.host.clone(), + port: intent.destination.port, + binary_path: binary_path.clone(), + binary_sha256: binary_sha256.clone(), + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + }; + match engine.evaluate_network_action_with_generation(&input) { + Ok((action, generation)) => EgressDecision { + intent, + action, + policy_generation: generation, + identity: ProcessIdentityEvidence::Available, + endpoint: EndpointDecision::default(), + binary: Some(binary_path.clone()), + binary_pid: None, + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + }, + Err(error) => EgressDecision { + intent, + action: NetworkAction::Deny { + reason: format!("policy evaluation error: {error}"), + }, + policy_generation: engine.current_generation(), + identity: ProcessIdentityEvidence::Available, + endpoint: EndpointDecision::default(), + binary: Some(binary_path.clone()), + binary_pid: None, + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + }, + } + } } } @@ -3653,16 +3732,16 @@ fn is_cloud_metadata_ip(ip: IpAddr) -> bool { } } -/// Read the proxy's own `/etc/hosts` at startup and return the IP mapped to +/// Read the proxy's own platform hosts file at startup and return the IP mapped to /// `host.openshell.internal`, if present and safe. /// /// This is called once before user code runs, so the returned value is immune -/// to later `/etc/hosts` tampering by sandbox workloads. Returns `None` if no +/// to later hosts-file tampering by sandbox workloads. Returns `None` if no /// entry exists, the entry cannot be parsed, or the mapped IP is a cloud /// metadata address. -#[cfg(any(target_os = "linux", test))] +#[cfg(any(target_os = "linux", target_os = "windows", test))] pub(crate) fn detect_trusted_host_gateway() -> Option { - let contents = std::fs::read_to_string("/etc/hosts").ok()?; + let contents = std::fs::read_to_string(platform_hosts_path()).ok()?; let ips = parse_hosts_file_for_host(&contents, "host.openshell.internal"); // Multiple distinct IPs for the alias is unexpected — compute drivers @@ -3674,7 +3753,7 @@ pub(crate) fn detect_trusted_host_gateway() -> Option { if ips.len() > 1 { warn!( ips = ?ips, - "host.openshell.internal has {} distinct IPs in /etc/hosts; \ + "host.openshell.internal has {} distinct IPs in the platform hosts file; \ expected exactly one. Using first entry. \ Connections resolving to any other IP will be rejected.", ips.len() @@ -3708,11 +3787,26 @@ pub(crate) fn detect_trusted_host_gateway() -> Option { Some(ip) } -#[cfg(not(any(target_os = "linux", test)))] +#[cfg(not(any(target_os = "linux", target_os = "windows", test)))] pub(crate) fn detect_trusted_host_gateway() -> Option { None } +#[cfg(target_os = "linux")] +fn platform_hosts_path() -> &'static str { + "/etc/hosts" +} + +#[cfg(target_os = "windows")] +fn platform_hosts_path() -> &'static str { + r"C:\Windows\System32\drivers\etc\hosts" +} + +#[cfg(all(test, not(any(target_os = "linux", target_os = "windows"))))] +fn platform_hosts_path() -> &'static str { + "/etc/hosts" +} + /// Resolve `host:port` and validate that every resolved address matches the /// trusted host gateway IP. /// @@ -3779,7 +3873,7 @@ fn resolve_ip_literal(host: &str, port: u16) -> Option> { .map(|ip| vec![SocketAddr::new(ip, port)]) } -#[cfg(any(target_os = "linux", test))] +#[cfg(any(target_os = "linux", target_os = "windows", test))] fn parse_hosts_file_for_host(contents: &str, host: &str) -> Vec { let lookup_host = normalize_host_lookup_key(host); let mut addrs = Vec::new(); @@ -4820,8 +4914,7 @@ async fn handle_forward_proxy( used: usize, client: &mut TcpStream, opa_engine: Arc, - identity_cache: Arc, - entrypoint_pid: Arc, + identity_mode: Arc, policy_local_ctx: Option>, agent_proposals: openshell_core::proposals::AgentProposals, trusted_host_gateway: Arc>, @@ -4927,11 +5020,10 @@ async fn handle_forward_proxy( let connection = crate::procfs::WorkloadProxyTcpConnection::new(workload_addr, proxy_addr); let opa_clone = opa_engine.clone(); - let cache_clone = identity_cache.clone(); - let pid_clone = entrypoint_pid.clone(); + let identity_clone = identity_mode.clone(); let intent = EgressIntent::forward_http(host_lc.clone(), port); let mut decision = tokio::task::spawn_blocking(move || { - authorize_egress_intent(connection, &opa_clone, &cache_clone, &pid_clone, intent) + authorize_egress_intent(connection, &opa_clone, &identity_clone, intent) }) .await .map_err(|e| miette::miette!("identity resolution task panicked: {e}"))?; @@ -5022,7 +5114,7 @@ async fn handle_forward_proxy( action = ?decision.action, "Forward proxy L4 policy decision" ); - let sandbox_entrypoint_pid = entrypoint_pid.load(Ordering::Acquire); + let sandbox_entrypoint_pid = identity_mode.entrypoint_pid(); let forward_generation_guard = match relay::pin_policy_generation( &opa_engine, decision.policy_generation, diff --git a/crates/openshell-supervisor-network/src/run.rs b/crates/openshell-supervisor-network/src/run.rs index 2a71702b4b..5ea8e166cc 100644 --- a/crates/openshell-supervisor-network/src/run.rs +++ b/crates/openshell-supervisor-network/src/run.rs @@ -29,6 +29,7 @@ use openshell_core::denial::DenialEvent; use openshell_core::proposals::AgentProposals; use tokio::sync::mpsc::UnboundedSender; +#[cfg(target_os = "linux")] use crate::identity::BinaryIdentityCache; use crate::l7::tls::{ CertCache, ProxyTlsState, SandboxCa, build_upstream_client_config, read_system_ca_bundle, @@ -36,7 +37,7 @@ use crate::l7::tls::{ }; use crate::opa::OpaEngine; use crate::policy_local::PolicyLocalContext; -use crate::proxy::ProxyHandle; +use crate::proxy::{ProxyHandle, ProxyIdentityMode}; #[cfg(target_os = "linux")] pub struct TransparentRuntimeSetup { @@ -309,8 +310,9 @@ pub async fn run_networking( let _ = engine_ready_tx.send(true); } - // Identity cache for SHA256 TOFU when OPA is active. Only consumed by - // the proxy, so it's owned here. + // Linux procfs identity mode uses a SHA256 TOFU cache. Windows host mode + // uses an explicit static sandbox identity instead. + #[cfg(target_os = "linux")] let identity_cache = opa_engine.map(|_| Arc::new(BinaryIdentityCache::new())); // Generate ephemeral CA and TLS state for HTTPS L7 inspection. @@ -411,10 +413,6 @@ pub async fn run_networking( miette::miette!("Proxy mode requires an OPA engine (--rego-policy and --rego-data)") })?; - let cache = identity_cache.clone().ok_or_else(|| { - miette::miette!("Proxy mode requires an identity cache (OPA engine must be configured)") - })?; - // If the orchestrator gave us a proxy bind IP (the host-side veth IP // from the workload's netns on Linux), use it so only traffic // originating inside the namespace can reach the proxy. Otherwise the @@ -433,12 +431,25 @@ pub async fn run_networking( ) .await?; + #[cfg(target_os = "linux")] + let identity_mode = { + let cache = identity_cache.clone().ok_or_else(|| { + miette::miette!( + "Proxy mode requires an identity cache (OPA engine must be configured)" + ) + })?; + ProxyIdentityMode::procfs(cache, entrypoint_pid.clone()) + }; + #[cfg(target_os = "windows")] + let identity_mode = ProxyIdentityMode::static_binary("openshell-windows-host-proxy"); + #[cfg(all(not(target_os = "linux"), not(target_os = "windows")))] + let identity_mode = ProxyIdentityMode::static_binary("openshell-supervisor-host-proxy"); + let proxy_handle = ProxyHandle::start_with_bind_addr( proxy_policy, bind_addr, engine, - cache, - entrypoint_pid.clone(), + Arc::new(identity_mode), tls_state, inference_ctx, Some(provider_credentials.clone()), From a6b2f340049ff56125a4847c82a6a85487c8a859 Mon Sep 17 00:00:00 2001 From: Akber Raza Date: Tue, 7 Jul 2026 16:02:37 -0500 Subject: [PATCH 12/31] Update README and gateway config to clarify egress proxy address handling and allocation --- crates/openshell-driver-mxc/README.md | 12 +- crates/openshell-driver-mxc/src/driver.rs | 174 ++++++++++++++++++++-- docs/reference/gateway-config.mdx | 32 ++++ 3 files changed, 206 insertions(+), 12 deletions(-) diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 436c6255fc..1dc41256cf 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -41,9 +41,17 @@ backend = "process_container" default_configuration_id = "composable" pc_least_privilege = false pc_capabilities = [] +# Pattern-C governed egress. The address is a loopback seed; each sandbox +# receives a unique ephemeral proxy port. +egress_proxy = false +egress_proxy_addr = "" debug = false ``` +When `egress_proxy` is enabled, `egress_proxy_addr` must be a loopback +`IP:PORT` seed. The driver preserves the configured IP and allocates a unique +ephemeral port for each sandbox's `network.proxy` redirect. + 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 @@ -54,7 +62,7 @@ openshell sandbox create --name mxc-demo --policy demo.yaml ` 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. +The host CONNECT proxy enforces network policy when governed egress is enabled. Live policy replacement or merge updates remain unsupported; delete and recreate the sandbox to apply a different policy. ## Prerequisites (live runs) @@ -78,7 +86,7 @@ procfs socket ownership. 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. -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. +If governed egress is disabled, any network rule fails closed rather than launching without an enforcement path. 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. diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index fb574483a1..98b70c3682 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, MappedConfig, PolicyMapper}; use futures::Stream; use openshell_core::gpu::{driver_gpu_requirements, effective_driver_gpu_count}; @@ -71,8 +71,9 @@ pub struct MxcComputeConfig { /// grants plus a `network.proxy` redirect and the host CONNECT proxy /// receives the 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 added by the follow-up commit. + /// Loopback `IP:PORT` seed for MXC `network.proxy` while governed egress is + /// enabled. The driver preserves the loopback IP and allocates a unique + /// ephemeral port per sandbox. pub egress_proxy_addr: String, /// Enable `--debug` flag on `wxc-exec` invocations. @@ -293,6 +294,14 @@ fn configured_egress_addr(config: &MxcComputeConfig) -> Result std::io::Result<(SocketAddr, std::net::TcpListener)> { + let reservation = std::net::TcpListener::bind(SocketAddr::new(configured.ip(), 0))?; + let addr = reservation.local_addr()?; + Ok((addr, reservation)) +} + fn encode_windows_command_line(args: &[String]) -> String { args.iter() .map(|arg| quote_windows_argument(arg)) @@ -440,7 +449,19 @@ impl MxcComputeBackend { let policy = self.pending_policies.lock().await.remove(&sandbox_id); self.validate_sandbox_create(sandbox)?; let sandbox_config = sandbox_config(sandbox)?; - let egress_addr = configured_egress_addr(&self.config)?; + let (egress_addr, reserved_proxy_listener) = match configured_egress_addr(&self.config)? { + Some(configured_addr) => { + let (addr, reservation) = allocate_sandbox_proxy_addr(configured_addr).map_err( + |error| { + tonic::Status::internal(format!( + "failed to allocate sandbox-unique MXC host egress proxy address from {configured_addr}: {error}" + )) + }, + )?; + (Some(addr), Some(reservation)) + } + None => (None, None), + }; // Policy translation is deterministic and side-effect free. Do it before // inserting the registry entry or launching MXC so invalid requests fail @@ -525,6 +546,7 @@ impl MxcComputeBackend { sandbox, sandbox_config, mapped, + reserved_proxy_listener, startup_guard, ) .await; @@ -722,6 +744,7 @@ async fn run_lifecycle( sandbox: DriverSandbox, sandbox_config: MxcSandboxConfig, mapped: MappedConfig, + mut reserved_proxy_listener: Option, _startup_guard: tokio::sync::OwnedMutexGuard<()>, ) { let sandbox_id = sandbox.id.clone(); @@ -731,6 +754,7 @@ async fn run_lifecycle( let host_proxy = if !invoker.is_mock() && let (Some(addr), Some(proxy_policy)) = (proxy_addr, trimmed_policy.clone()) { + drop(reserved_proxy_listener.take()); match openshell_supervisor_network::host::start_host_proxy( openshell_supervisor_network::host::HostProxyConfig { bind_addr: addr, @@ -764,6 +788,7 @@ async fn run_lifecycle( } else { None }; + drop(reserved_proxy_listener.take()); if let Some(addr) = proxy_addr { { let mut registry = registry.lock().await; @@ -794,11 +819,15 @@ async fn run_lifecycle( env: sandbox_environment(&sandbox), timeout: 0, }; + let network = proxy_addr.map(|addr| MxcNetwork { + default_policy: "block".into(), + proxy: Some(addr), + }); let child = match config.backend { MxcBackend::IsolationSession => { let iso_sandbox_id = match invoker - .provision(&config.default_configuration_id, filesystem, None) + .provision(&config.default_configuration_id, filesystem, network) .await { Ok(id) => id, @@ -856,7 +885,7 @@ async fn run_lifecycle( capabilities: config.pc_capabilities.clone(), }; match invoker - .run_oneshot(&sandbox_id, filesystem, process_container, process, None) + .run_oneshot(&sandbox_id, filesystem, process_container, process, network) .await { Ok(child) => child, @@ -1147,10 +1176,18 @@ mod lifecycle_tests { #[test] fn mxc_config_defaults_to_default_deny_process_container() { - assert_eq!( - MxcComputeConfig::default().backend, - MxcBackend::ProcessContainer - ); + let config = MxcComputeConfig::default(); + assert_eq!(config.backend, MxcBackend::ProcessContainer); + assert!(!config.egress_proxy); + assert!(config.egress_proxy_addr.is_empty()); + } + + #[test] + fn sandbox_proxy_addr_uses_ephemeral_loopback_port() { + let configured = "127.0.0.1:18080".parse().unwrap(); + let (addr, _reservation) = allocate_sandbox_proxy_addr(configured).unwrap(); + assert_eq!(addr.ip(), configured.ip()); + assert_ne!(addr.port(), 0); } #[test] @@ -1297,6 +1334,123 @@ 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 = MxcComputeConfig::default(); + 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()); + + let sandbox = driver_sandbox_with_command("sb-egress", &share, cmd); + backend + .create_sandbox(&sandbox) + .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}}. + let proxy_port = recorded["network"]["proxy"]["localhost"] + .as_u64() + .expect("proxy localhost port"); + assert!(proxy_port > 0); + assert!(proxy_port <= u64::from(u16::MAX)); + 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"); + let entry_proxy_addr = entry.proxy_addr.expect("proxy addr"); + assert_eq!( + entry_proxy_addr.ip(), + std::net::IpAddr::from([127, 0, 0, 1]) + ); + assert_eq!(u64::from(entry_proxy_addr.port()), proxy_port); + 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(); diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 32d93aa2e6..c0bc1ffea4 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -759,6 +759,38 @@ health_check_interval_secs = 10 # proxy_ca_bundle = "/etc/openshell/tls/proxy-ca.pem" ``` +### MXC + +The MXC driver is Windows-only and opt-in. It links into the gateway, invokes Microsoft MXC through `wxc-exec.exe`, and runs each sandbox's configured command in-driver instead of using the Linux sandbox supervisor. + +```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 (default) or isolation_session. +backend = "process_container" +default_configuration_id = "composable" +pc_least_privilege = false +pc_capabilities = [] +# Pattern-C governed egress. The address is a loopback seed; the driver +# allocates a unique ephemeral port for each sandbox. +egress_proxy = false +egress_proxy_addr = "" +debug = false +etw_audit = false +``` + +Set `egress_proxy = true` with a loopback seed such as `egress_proxy_addr = "127.0.0.1:18080"` to enable the Windows Pattern-C split. MXC redirects sandbox traffic to a sandbox-unique port on the configured loopback IP, where the host CONNECT proxy enforces the trimmed network policy. Governed egress requires `process_container` until MXC supports `network.proxy` for isolation sessions. + +Supply the workload command and optional working directory through the sandbox's `mxc` driver configuration, for example `{"mxc":{"command":["cmd","/c","echo hello"],"cwd":"C:\\work"}}`. + ### MicroVM Each sandbox runs inside its own libkrun microVM managed by the standalone `openshell-driver-vm` subprocess. Use this driver when you want stronger isolation than container namespaces alone. From 56cf1e813d26ad92be7aba78265332baf7bda384 Mon Sep 17 00:00:00 2001 From: Akber Raza Date: Tue, 7 Jul 2026 19:36:42 -0500 Subject: [PATCH 13/31] Refactor ProxyIdentityMode to return Result for static_binary and add tests for binary path and SHA256 hash --- .../openshell-supervisor-network/src/host.rs | 2 +- .../openshell-supervisor-network/src/proxy.rs | 55 +++++++++++++------ .../src/proxy/tests/compatibility.rs | 21 ++++--- .../openshell-supervisor-network/src/run.rs | 11 +++- 4 files changed, 60 insertions(+), 29 deletions(-) diff --git a/crates/openshell-supervisor-network/src/host.rs b/crates/openshell-supervisor-network/src/host.rs index 8d96f49583..a03e04e647 100644 --- a/crates/openshell-supervisor-network/src/host.rs +++ b/crates/openshell-supervisor-network/src/host.rs @@ -100,7 +100,7 @@ pub async fn start_host_proxy(config: HostProxyConfig) -> Result) -> Self { - Self::Static { - binary_path: path.into(), - binary_sha256: "openshell-host-proxy-static-identity".to_string(), - } + pub(crate) fn static_binary(path: impl Into) -> Result { + let binary_path = path.into(); + let binary_sha256 = crate::procfs::file_sha256(&binary_path)?; + Ok(Self::Static { + binary_path, + binary_sha256, + }) } fn entrypoint_pid(&self) -> u32 { @@ -6614,8 +6616,7 @@ network_policies: {} Box::pin(handle_tcp_connection( server, engine, - Arc::new(BinaryIdentityCache::new()), - Arc::new(AtomicU32::new(std::process::id())), + Arc::new(ProxyIdentityMode::static_binary(std::env::current_exe().unwrap()).unwrap()), None, None, None, @@ -6729,8 +6730,9 @@ network_policies: request.len(), &mut proxy_connection, engine, - Arc::new(BinaryIdentityCache::new()), - Arc::new(AtomicU32::new(std::process::id())), + Arc::new( + ProxyIdentityMode::static_binary(std::env::current_exe().unwrap()).unwrap(), + ), None, AgentProposals::default(), Arc::new(None), @@ -6862,8 +6864,9 @@ network_policies: request.len(), &mut proxy_connection, engine, - Arc::new(BinaryIdentityCache::new()), - Arc::new(AtomicU32::new(std::process::id())), + Arc::new( + ProxyIdentityMode::static_binary(std::env::current_exe().unwrap()).unwrap(), + ), None, AgentProposals::default(), Arc::new(None), @@ -7430,6 +7433,27 @@ network_policies: ); } + #[test] + fn static_binary_hashes_configured_file() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), b"abc").unwrap(); + + match ProxyIdentityMode::static_binary(tmp.path()).unwrap() { + ProxyIdentityMode::Static { + binary_path, + binary_sha256, + } => { + assert_eq!(binary_path, tmp.path()); + assert_eq!( + binary_sha256, + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + #[cfg(target_os = "linux")] + ProxyIdentityMode::Procfs { .. } => panic!("expected static identity mode"), + } + } + #[test] fn tunnel_protocol_prefix_detection_waits_for_partial_supported_prefixes() { assert!(could_be_supported_tunnel_protocol_prefix(&[0x16])); @@ -12175,8 +12199,7 @@ network_policies: }); let (server, _peer) = listener.accept().await.unwrap(); - let entrypoint_pid = Arc::new(AtomicU32::new(std::process::id())); - let cache = Arc::new(BinaryIdentityCache::new()); + let identity_mode = Arc::new(ProxyIdentityMode::static_binary(exe).unwrap()); let (denial_tx, mut denial_rx) = mpsc::unbounded_channel(); let completed = tokio::time::timeout( @@ -12184,8 +12207,7 @@ network_policies: Box::pin(handle_tcp_connection( server, engine, - cache, - entrypoint_pid, + identity_mode, None, // tls_state — ephemeral CA unavailable None, // inference_ctx None, // policy_local_ctx @@ -12251,8 +12273,7 @@ network_policies: Box::pin(handle_tcp_connection( server, engine, - Arc::new(BinaryIdentityCache::new()), - Arc::new(AtomicU32::new(std::process::id())), + Arc::new(ProxyIdentityMode::static_binary(exe).unwrap()), None, None, None, diff --git a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs index 186d156086..bc2f0e1e6c 100644 --- a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs +++ b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs @@ -381,28 +381,27 @@ network_policies: #[cfg(not(target_os = "linux"))] #[test] -fn identity_required_mode_is_explicitly_unsupported_off_linux() { +fn static_identity_is_supported_off_linux() { let engine = OpaEngine::from_strings( include_str!("../../../data/sandbox-policy.rego"), "network_policies: {}\n", ) .unwrap(); + let binary = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(binary.path(), b"agent").unwrap(); + let identity = ProxyIdentityMode::static_binary(binary.path()).unwrap(); let decision = authorize_egress_intent( crate::procfs::WorkloadProxyTcpConnection::new( "127.0.0.1:41000".parse().unwrap(), "127.0.0.1:3000".parse().unwrap(), ), &engine, - &BinaryIdentityCache::new(), - &AtomicU32::new(1), + &identity, EgressIntent::connect("target.example".to_string(), 443), ); assert!(matches!(decision.action, NetworkAction::Deny { .. })); - assert_eq!( - decision.identity, - ProcessIdentityEvidence::Unavailable(IdentityUnavailableReason::UnsupportedPlatform) - ); + assert_eq!(decision.identity, ProcessIdentityEvidence::Available); } #[test] @@ -541,8 +540,12 @@ network_policies: Box::pin(handle_tcp_connection( stream, engine, - Arc::new(BinaryIdentityCache::new()), - Arc::new(AtomicU32::new(0)), + Arc::new( + ProxyIdentityMode::static_binary( + std::env::current_exe().unwrap(), + ) + .unwrap(), + ), None, None, None, diff --git a/crates/openshell-supervisor-network/src/run.rs b/crates/openshell-supervisor-network/src/run.rs index 5ea8e166cc..16f9308eb4 100644 --- a/crates/openshell-supervisor-network/src/run.rs +++ b/crates/openshell-supervisor-network/src/run.rs @@ -162,6 +162,13 @@ pub struct Networking { _transparent_tcp: Option, } +#[cfg(not(target_os = "linux"))] +fn current_exe_static_identity_path() -> Result { + std::env::current_exe().map_err(|e| { + miette::miette!("failed to resolve supervisor executable for static proxy identity: {e}") + }) +} + /// Set up the networking stack: ephemeral CA + TLS state, proxy server, /// and the SSH-side proxy URL / netns FD. /// @@ -441,9 +448,9 @@ pub async fn run_networking( ProxyIdentityMode::procfs(cache, entrypoint_pid.clone()) }; #[cfg(target_os = "windows")] - let identity_mode = ProxyIdentityMode::static_binary("openshell-windows-host-proxy"); + let identity_mode = ProxyIdentityMode::static_binary(current_exe_static_identity_path()?)?; #[cfg(all(not(target_os = "linux"), not(target_os = "windows")))] - let identity_mode = ProxyIdentityMode::static_binary("openshell-supervisor-host-proxy"); + let identity_mode = ProxyIdentityMode::static_binary(current_exe_static_identity_path()?)?; let proxy_handle = ProxyHandle::start_with_bind_addr( proxy_policy, From 9b0e989844a8bcdb4a08c2d925f9368a333cd301 Mon Sep 17 00:00:00 2001 From: Akber Raza Date: Wed, 8 Jul 2026 09:49:07 -0500 Subject: [PATCH 14/31] Enhance platform_hosts_path for Windows to use SystemRoot and improve error handling for hosts file reading --- .../openshell-supervisor-network/src/proxy.rs | 43 ++++++++++++++----- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index af1a323fe8..87db0a891a 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -3738,12 +3738,23 @@ fn is_cloud_metadata_ip(ip: IpAddr) -> bool { /// `host.openshell.internal`, if present and safe. /// /// This is called once before user code runs, so the returned value is immune -/// to later hosts-file tampering by sandbox workloads. Returns `None` if no -/// entry exists, the entry cannot be parsed, or the mapped IP is a cloud -/// metadata address. +/// to later hosts-file tampering by sandbox workloads. Returns `None` if the +/// hosts file cannot be read, no entry exists, the entry cannot be parsed, or +/// the mapped IP is a cloud metadata address. #[cfg(any(target_os = "linux", target_os = "windows", test))] pub(crate) fn detect_trusted_host_gateway() -> Option { - let contents = std::fs::read_to_string(platform_hosts_path()).ok()?; + let hosts_path = platform_hosts_path(); + let contents = match std::fs::read_to_string(&hosts_path) { + Ok(contents) => contents, + Err(error) => { + warn!( + path = %hosts_path.display(), + %error, + "failed to read platform hosts file; trusted-gateway SSRF exemption disabled" + ); + return None; + } + }; let ips = parse_hosts_file_for_host(&contents, "host.openshell.internal"); // Multiple distinct IPs for the alias is unexpected — compute drivers @@ -3795,18 +3806,30 @@ pub(crate) fn detect_trusted_host_gateway() -> Option { } #[cfg(target_os = "linux")] -fn platform_hosts_path() -> &'static str { - "/etc/hosts" +fn platform_hosts_path() -> PathBuf { + PathBuf::from("/etc/hosts") +} + +#[cfg(target_os = "windows")] +fn platform_hosts_path() -> PathBuf { + windows_hosts_path_from_system_root(std::env::var("SystemRoot").ok().as_deref()) } #[cfg(target_os = "windows")] -fn platform_hosts_path() -> &'static str { - r"C:\Windows\System32\drivers\etc\hosts" +fn windows_hosts_path_from_system_root(system_root: Option<&str>) -> PathBuf { + let root = system_root + .filter(|value| !value.trim().is_empty()) + .unwrap_or(r"C:\Windows"); + PathBuf::from(root) + .join("System32") + .join("drivers") + .join("etc") + .join("hosts") } #[cfg(all(test, not(any(target_os = "linux", target_os = "windows"))))] -fn platform_hosts_path() -> &'static str { - "/etc/hosts" +fn platform_hosts_path() -> PathBuf { + PathBuf::from("/etc/hosts") } /// Resolve `host:port` and validate that every resolved address matches the From 582364a77ecb9727cf00bed90c0ffce07ac04aca Mon Sep 17 00:00:00 2001 From: Akber Raza Date: Wed, 8 Jul 2026 20:34:55 -0500 Subject: [PATCH 15/31] Refactor FileFingerprint to use Option for mtime and ctime, simplifying metadata handling --- .../src/identity.rs | 47 +++++++------------ 1 file changed, 17 insertions(+), 30 deletions(-) diff --git a/crates/openshell-supervisor-network/src/identity.rs b/crates/openshell-supervisor-network/src/identity.rs index e81a564224..643db06f5b 100644 --- a/crates/openshell-supervisor-network/src/identity.rs +++ b/crates/openshell-supervisor-network/src/identity.rs @@ -21,10 +21,8 @@ use tracing::debug; #[derive(Clone)] struct FileFingerprint { len: u64, - mtime_sec: i64, - mtime_nsec: i64, - ctime_sec: i64, - ctime_nsec: i64, + mtime: Option<(i64, i64)>, + ctime: Option<(i64, i64)>, #[cfg(unix)] dev: u64, #[cfg(unix)] @@ -34,32 +32,19 @@ struct FileFingerprint { impl FileFingerprint { fn from_metadata(metadata: &Metadata) -> Self { #[cfg(unix)] - let (mtime_sec, mtime_nsec, ctime_sec, ctime_nsec) = ( - metadata.mtime(), - metadata.mtime_nsec(), - metadata.ctime(), - metadata.ctime_nsec(), + let (mtime, ctime) = ( + Some((metadata.mtime(), metadata.mtime_nsec())), + Some((metadata.ctime(), metadata.ctime_nsec())), ); #[cfg(not(unix))] - let (mtime_sec, mtime_nsec, ctime_sec, ctime_nsec) = { - let (mtime_sec, mtime_nsec) = metadata - .modified() - .ok() - .and_then(system_time_parts) - .unwrap_or_default(); - let (ctime_sec, ctime_nsec) = metadata - .created() - .ok() - .and_then(system_time_parts) - .unwrap_or_default(); - (mtime_sec, mtime_nsec, ctime_sec, ctime_nsec) - }; + let (mtime, ctime) = ( + metadata.modified().ok().and_then(system_time_parts), + metadata.created().ok().and_then(system_time_parts), + ); Self { len: metadata.len(), - mtime_sec, - mtime_nsec, - ctime_sec, - ctime_nsec, + mtime, + ctime, #[cfg(unix)] dev: metadata.dev(), #[cfg(unix)] @@ -80,10 +65,12 @@ fn system_time_parts(time: std::time::SystemTime) -> Option<(i64, i64)> { impl PartialEq for FileFingerprint { fn eq(&self, other: &Self) -> bool { self.len == other.len - && self.mtime_sec == other.mtime_sec - && self.mtime_nsec == other.mtime_nsec - && self.ctime_sec == other.ctime_sec - && self.ctime_nsec == other.ctime_nsec + && self.mtime.is_some() + && other.mtime.is_some() + && self.mtime == other.mtime + && self.ctime.is_some() + && other.ctime.is_some() + && self.ctime == other.ctime && { #[cfg(unix)] { From 8e6561b6f311c5f85473b3c7275ede6a9efe67dc Mon Sep 17 00:00:00 2001 From: Akber Raza Date: Thu, 9 Jul 2026 02:31:25 -0500 Subject: [PATCH 16/31] Add conditional compilation for Windows host module --- crates/openshell-supervisor-network/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/openshell-supervisor-network/src/lib.rs b/crates/openshell-supervisor-network/src/lib.rs index a69e0a78ef..80c27ab894 100644 --- a/crates/openshell-supervisor-network/src/lib.rs +++ b/crates/openshell-supervisor-network/src/lib.rs @@ -8,6 +8,7 @@ //! owned by the orchestrator; this crate produces denials but does not //! aggregate them. +#[cfg(target_os = "windows")] pub mod host; pub mod identity; pub mod inference_routes; From 6c58abb8bb00999c8d3118dfe280d9382a15d23d Mon Sep 17 00:00:00 2001 From: Akber Raza Date: Tue, 14 Jul 2026 12:01:17 -0500 Subject: [PATCH 17/31] add unit tests for OPA policy evaluation and identity handling --- .../openshell-supervisor-network/src/host.rs | 98 +++++++++++++++++++ .../openshell-supervisor-network/src/proxy.rs | 76 ++++++++++++++ 2 files changed, 174 insertions(+) diff --git a/crates/openshell-supervisor-network/src/host.rs b/crates/openshell-supervisor-network/src/host.rs index a03e04e647..f65c9d2e70 100644 --- a/crates/openshell-supervisor-network/src/host.rs +++ b/crates/openshell-supervisor-network/src/host.rs @@ -120,3 +120,101 @@ pub async fn start_host_proxy(config: HostProxyConfig) -> Result HostProxyConfig { + HostProxyConfig { + bind_addr, + policy: ProtoSandboxPolicy { + version: 1, + ..Default::default() + }, + binary_path, + sandbox_id: Some("sandbox-123".to_string()), + sandbox_name: Some("agent-box".to_string()), + openshell_endpoint: None, + inference_routes: None, + provider_credentials: None, + agent_proposals: AgentProposals::new(true), + denial_tx: None, + activity_tx: None, + } + } + + #[tokio::test] + async fn rejects_non_loopback_bind_addr() { + let result = start_host_proxy(test_config( + ([192, 0, 2, 1], 0).into(), + PathBuf::from("missing-agent.exe"), + )) + .await; + + let Err(err) = result else { + panic!("host proxy should reject non-loopback bind addresses"); + }; + assert!( + err.to_string().contains("loopback-only"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn starts_loopback_proxy_and_serves_policy_local() { + let binary = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(binary.path(), b"agent").unwrap(); + + let handle = start_host_proxy(test_config( + ([127, 0, 0, 1], 0).into(), + binary.path().to_path_buf(), + )) + .await + .unwrap(); + + let addr = handle.http_addr().expect("proxy should report bound addr"); + assert!(addr.ip().is_loopback()); + assert_ne!(addr.port(), 0); + + let mut client = TcpStream::connect(addr).await.unwrap(); + client + .write_all( + b"GET http://policy.local/v1/policy/current HTTP/1.1\r\n\ + Host: policy.local\r\n\ + Connection: close\r\n\ + \r\n", + ) + .await + .unwrap(); + + let mut response = Vec::new(); + tokio::time::timeout(Duration::from_secs(2), client.read_to_end(&mut response)) + .await + .unwrap() + .unwrap(); + + let response = String::from_utf8(response).unwrap(); + assert!( + response.starts_with("HTTP/1.1 200 OK"), + "unexpected response: {response}" + ); + let (_, body) = response.split_once("\r\n\r\n").expect("response body"); + let body: serde_json::Value = serde_json::from_str(body).unwrap(); + assert_eq!(body["format"], "yaml"); + assert!( + body["policy_yaml"] + .as_str() + .unwrap_or_default() + .contains("version: 1"), + "unexpected policy payload: {body}" + ); + } +} diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 87db0a891a..9ccb755672 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -6465,6 +6465,9 @@ fn is_benign_relay_error(err: &miette::Report) -> bool { mod tests { use super::*; use openshell_core::proposals::AgentProposals; + use openshell_core::proto::{ + NetworkBinary, NetworkEndpoint, NetworkPolicyRule, SandboxPolicy as ProtoSandboxPolicy, + }; use std::collections::HashMap as TestHashMap; use std::future::Future; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; @@ -7477,6 +7480,79 @@ network_policies: } } + #[test] + fn static_identity_evaluate_opa_tcp_allows_and_denies_with_proto_policy() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), b"static-agent").unwrap(); + let identity = ProxyIdentityMode::static_binary(tmp.path()).unwrap(); + let binary_path = tmp.path().to_string_lossy().into_owned(); + let policy_name = "static_agent"; + let engine = OpaEngine::from_proto(&ProtoSandboxPolicy { + version: 1, + network_policies: std::collections::HashMap::from([( + policy_name.to_string(), + NetworkPolicyRule { + name: policy_name.to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.example.test".to_string(), + port: 443, + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: binary_path, + ..Default::default() + }], + }, + )]), + ..Default::default() + }) + .unwrap(); + let peer_addr: SocketAddr = ([127, 0, 0, 1], 49152).into(); + let connection = crate::procfs::WorkloadProxyTcpConnection::new( + peer_addr, + ([127, 0, 0, 1], 18080).into(), + ); + + let allowed = authorize_egress_intent( + connection, + &engine, + &identity, + EgressIntent::connect("api.example.test".to_string(), 443), + ); + match allowed.action { + NetworkAction::Allow { matched_policy } => { + assert_eq!(matched_policy.as_deref(), Some(policy_name)); + } + NetworkAction::Deny { reason } => panic!("expected allow, got deny: {reason}"), + } + assert_eq!(allowed.binary.as_deref(), Some(tmp.path())); + assert_eq!(allowed.binary_pid, None); + assert!(allowed.ancestors.is_empty()); + assert!(allowed.cmdline_paths.is_empty()); + + let denied = authorize_egress_intent( + connection, + &engine, + &identity, + EgressIntent::connect("blocked.example.test".to_string(), 443), + ); + match denied.action { + NetworkAction::Allow { matched_policy } => { + panic!("expected deny, got allow from policy {matched_policy:?}"); + } + NetworkAction::Deny { reason } => { + assert!( + reason.contains("endpoint blocked.example.test:443 is not allowed"), + "unexpected deny reason: {reason}" + ); + } + } + assert_eq!(denied.binary.as_deref(), Some(tmp.path())); + assert_eq!(denied.binary_pid, None); + assert!(denied.ancestors.is_empty()); + assert!(denied.cmdline_paths.is_empty()); + } + #[test] fn tunnel_protocol_prefix_detection_waits_for_partial_supported_prefixes() { assert!(could_be_supported_tunnel_protocol_prefix(&[0x16])); From 198481594e11757fd53ac5a92c2bd137a953e9fc Mon Sep 17 00:00:00 2001 From: Akber Raza Date: Tue, 14 Jul 2026 19:27:13 -0500 Subject: [PATCH 18/31] remove openshell-supervisor-network from unsupported driver package test exclusion list --- tasks/scripts/windows-msvc.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tasks/scripts/windows-msvc.ps1 b/tasks/scripts/windows-msvc.ps1 index cf1b4c8686..312b610f53 100644 --- a/tasks/scripts/windows-msvc.ps1 +++ b/tasks/scripts/windows-msvc.ps1 @@ -63,7 +63,7 @@ if (-not [int]::TryParse($BuildJobsValue, [ref] $WindowsBuildJobs) -or $WindowsB } $WindowsCargoMutex = [System.Threading.Mutex]::new($false, "Local\OpenShellWindowsMsvcCargo") -$UnsupportedDriverPackageExcludes = "--exclude openshell-driver-docker --exclude openshell-driver-kubernetes --exclude openshell-driver-kubernetes-secrets --exclude openshell-driver-podman --exclude openshell-driver-vault --exclude openshell-driver-vm --exclude openshell-sandbox --exclude openshell-supervisor-network --exclude openshell-supervisor-process --exclude openshell-vfio" +$UnsupportedDriverPackageExcludes = "--exclude openshell-driver-docker --exclude openshell-driver-kubernetes --exclude openshell-driver-kubernetes-secrets --exclude openshell-driver-podman --exclude openshell-driver-vault --exclude openshell-driver-vm --exclude openshell-sandbox --exclude openshell-supervisor-process --exclude openshell-vfio" $WindowsClippyPackageExcludes = $UnsupportedDriverPackageExcludes $WindowsClippyLintArgs = "-D warnings -A dead-code -A unused-imports -A clippy::unused-async" $BundledZ3WorkspaceFeatures = "--features openshell-prover/bundled-z3" From 293836166e9834dcf0d4a9270fba94830fafd87f Mon Sep 17 00:00:00 2001 From: Akber Raza Date: Tue, 21 Jul 2026 21:02:44 -0500 Subject: [PATCH 19/31] feat(mxc): enable host proxy TLS state generation Generate per-sandbox TLS state for the MXC host proxy so HTTPS L7 enforcement can use the same MITM path as Linux. Grant generated CA material to the MXC process and inject standard trust env vars, while matching Linux behavior by disabling TLS termination on CA setup failure and relying on proxy fail-closed handling. --- crates/openshell-driver-mxc/README.md | 10 +- crates/openshell-driver-mxc/src/driver.rs | 103 ++++++++++++++- .../openshell-supervisor-network/Cargo.toml | 2 +- .../openshell-supervisor-network/src/host.rs | 117 +++++++++++++++++- 4 files changed, 222 insertions(+), 10 deletions(-) diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 1dc41256cf..efe25affff 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -16,7 +16,7 @@ readiness; there is no in-sandbox supervisor or `ConnectSupervisor` relay. | 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`; the driver starts a per-sandbox host CONNECT proxy from the trimmed network policy | HTTPS MITM trust bootstrap and gateway event-bus wiring follow-on | +| Governed egress (CONNECT proxy + OPA + L7) | Available behind `egress_proxy` on `process_container`; the driver starts a per-sandbox host CONNECT proxy, generates HTTPS MITM trust material, and injects the CA bundle into the sandbox process env | Gateway event-bus wiring follow-on | | 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 | gateway interactive-exec surgery (follow-on) | @@ -82,7 +82,11 @@ instead: MXC receives filesystem grants plus a loopback `network.proxy` redirect, and the driver starts a host CONNECT proxy from the trimmed network-only `SandboxPolicy`. The proxy uses the configured agent command as the static sandbox process identity because MXC does not expose Linux-style -procfs socket ownership. The development export surface remains the +procfs socket ownership. For HTTPS L7 inspection, the host proxy generates a +per-sandbox CA, grants the CA directory read-only in MXC, and injects +`NODE_EXTRA_CA_CERTS`, `DENO_CERT`, `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`, +`CURL_CA_BUNDLE`, and `GIT_SSL_CAINFO` into the agent process env. 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. @@ -131,6 +135,6 @@ velocity keys not enabled, isolation_session absent). ## Deferred work - **Interactive exec/connect/forward** — gateway interactive-exec surgery (follow-on) -- **Governed egress polish** — HTTPS MITM trust bootstrap, gateway denial/activity bus wiring, and per-sandbox port allocation +- **Governed egress polish** — gateway denial/activity bus wiring, broader real-MXC HTTPS L7 scenario coverage, and per-sandbox port allocation - **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 index 98b70c3682..ee4c260b43 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -343,6 +343,58 @@ fn host_proxy_binary_path(config: &MxcSandboxConfig) -> PathBuf { .map_or_else(|| PathBuf::from("mxc-agent"), PathBuf::from) } +const TLS_ENV_KEYS: [&str; 6] = [ + "NODE_EXTRA_CA_CERTS", + "DENO_CERT", + "SSL_CERT_FILE", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "GIT_SSL_CAINFO", +]; + +fn append_tls_env_vars(env: &mut Vec, ca_paths: Option<&(PathBuf, PathBuf)>) { + let Some((ca_cert_path, combined_bundle_path)) = ca_paths else { + return; + }; + + env.retain(|entry| { + let key = entry.split_once('=').map_or(entry.as_str(), |(key, _)| key); + !TLS_ENV_KEYS + .iter() + .any(|candidate| key.eq_ignore_ascii_case(candidate)) + }); + + let ca_cert_path = ca_cert_path.display().to_string(); + let combined_bundle_path = combined_bundle_path.display().to_string(); + env.extend([ + format!("NODE_EXTRA_CA_CERTS={ca_cert_path}"), + format!("DENO_CERT={ca_cert_path}"), + format!("SSL_CERT_FILE={combined_bundle_path}"), + format!("REQUESTS_CA_BUNDLE={combined_bundle_path}"), + format!("CURL_CA_BUNDLE={combined_bundle_path}"), + format!("GIT_SSL_CAINFO={combined_bundle_path}"), + ]); +} + +fn append_tls_readonly_grant( + readonly_paths: &mut Vec, + ca_paths: Option<&(PathBuf, PathBuf)>, +) { + let Some((ca_cert_path, _)) = ca_paths else { + return; + }; + let Some(dir) = ca_cert_path.parent() else { + return; + }; + let dir = dir.display().to_string(); + if !readonly_paths + .iter() + .any(|existing| existing.eq_ignore_ascii_case(&dir)) + { + readonly_paths.push(dir); + } +} + impl MxcComputeBackend { pub fn new(config: MxcComputeConfig) -> Self { let invoker = WxcExecInvoker::new(&config.wxc_exec_path, config.debug); @@ -788,6 +840,7 @@ async fn run_lifecycle( } else { None }; + let host_proxy_ca_paths = host_proxy.as_ref().and_then(|proxy| proxy.ca_file_paths()); drop(reserved_proxy_listener.take()); if let Some(addr) = proxy_addr { { @@ -805,18 +858,22 @@ async fn run_lifecycle( )); } + let mut readonly_paths = mapped.readonly_paths; + append_tls_readonly_grant(&mut readonly_paths, host_proxy_ca_paths.as_ref()); let filesystem = MxcFilesystem { readwrite_paths: mapped.readwrite_paths, - readonly_paths: mapped.readonly_paths, + readonly_paths, // OpenShell's policy model has no explicit deny field; default-deny is // implicit and enforced by processContainer at the OS boundary. denied_paths: Vec::new(), }; let command_line = encode_windows_command_line(&sandbox_config.command); + let mut environment = sandbox_environment(&sandbox); + append_tls_env_vars(&mut environment, host_proxy_ca_paths.as_ref()); let process = MxcProcess { command_line: command_line.clone(), cwd: sandbox_config.cwd, - env: sandbox_environment(&sandbox), + env: environment, timeout: 0, }; let network = proxy_addr.map(|addr| MxcNetwork { @@ -1207,6 +1264,48 @@ mod lifecycle_tests { ); } + #[test] + fn tls_env_vars_replace_user_trust_overrides() { + let ca_cert = PathBuf::from("C:\\openshell\\tls\\openshell-ca.pem"); + let bundle = PathBuf::from("C:\\openshell\\tls\\ca-bundle.pem"); + let mut env = vec![ + "FOO=bar".to_string(), + "SSL_CERT_FILE=C:\\old\\bundle.pem".to_string(), + "node_extra_ca_certs=C:\\old\\ca.pem".to_string(), + ]; + + append_tls_env_vars(&mut env, Some(&(ca_cert, bundle))); + + assert!(env.contains(&"FOO=bar".to_string())); + assert!( + !env.iter() + .any(|entry| entry == "SSL_CERT_FILE=C:\\old\\bundle.pem") + ); + assert!( + !env.iter() + .any(|entry| entry == "node_extra_ca_certs=C:\\old\\ca.pem") + ); + assert!( + env.contains(&"NODE_EXTRA_CA_CERTS=C:\\openshell\\tls\\openshell-ca.pem".to_string()) + ); + assert!(env.contains(&"DENO_CERT=C:\\openshell\\tls\\openshell-ca.pem".to_string())); + assert!(env.contains(&"SSL_CERT_FILE=C:\\openshell\\tls\\ca-bundle.pem".to_string())); + assert!(env.contains(&"REQUESTS_CA_BUNDLE=C:\\openshell\\tls\\ca-bundle.pem".to_string())); + assert!(env.contains(&"CURL_CA_BUNDLE=C:\\openshell\\tls\\ca-bundle.pem".to_string())); + assert!(env.contains(&"GIT_SSL_CAINFO=C:\\openshell\\tls\\ca-bundle.pem".to_string())); + } + + #[test] + fn tls_readonly_grant_adds_ca_directory_once() { + let ca_cert = PathBuf::from("C:\\openshell\\tls\\openshell-ca.pem"); + let bundle = PathBuf::from("C:\\openshell\\tls\\ca-bundle.pem"); + let mut readonly = vec!["c:\\openshell\\tls".to_string()]; + + append_tls_readonly_grant(&mut readonly, Some(&(ca_cert, bundle))); + + assert_eq!(readonly, vec!["c:\\openshell\\tls"]); + } + #[test] fn windows_command_line_preserves_argument_boundaries() { assert_eq!( diff --git a/crates/openshell-supervisor-network/Cargo.toml b/crates/openshell-supervisor-network/Cargo.toml index d090550dc9..b1d07a2d14 100644 --- a/crates/openshell-supervisor-network/Cargo.toml +++ b/crates/openshell-supervisor-network/Cargo.toml @@ -43,6 +43,7 @@ serde_yml = { workspace = true } sha1 = "0.10" sha2 = { workspace = true } spiffe = { workspace = true } +tempfile = "3" thiserror = { workspace = true } tokio = { workspace = true } tokio-rustls = { workspace = true } @@ -57,7 +58,6 @@ bundled-ca-roots = ["dep:webpki-roots"] [dev-dependencies] openshell-supervisor-middleware-builtins = { path = "../openshell-supervisor-middleware-builtins" } -tempfile = "3" tonic = { workspace = true } temp-env = "0.3" tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/openshell-supervisor-network/src/host.rs b/crates/openshell-supervisor-network/src/host.rs index f65c9d2e70..d7f8d17c2f 100644 --- a/crates/openshell-supervisor-network/src/host.rs +++ b/crates/openshell-supervisor-network/src/host.rs @@ -20,8 +20,15 @@ use openshell_core::policy::ProxyPolicy; use openshell_core::proposals::AgentProposals; use openshell_core::proto::SandboxPolicy as ProtoSandboxPolicy; use openshell_core::provider_credentials::ProviderCredentialState; +use openshell_ocsf::{ + ConfigStateChangeBuilder, SeverityId, StateId, StatusId, ctx::ctx as ocsf_ctx, ocsf_emit, +}; use tokio::sync::mpsc::UnboundedSender; +use crate::l7::tls::{ + CertCache, ProxyTlsState, SandboxCa, build_upstream_client_config, read_system_ca_bundle, + write_ca_files, +}; use crate::opa::OpaEngine; use crate::policy_local::PolicyLocalContext; use crate::proxy::{ProxyHandle, ProxyIdentityMode}; @@ -50,6 +57,9 @@ pub struct HostProxyConfig { /// RAII handle for a host-side proxy. Dropping it aborts the proxy accept loop. pub struct HostProxyHandle { proxy: ProxyHandle, + ca_file_paths: Option<(PathBuf, PathBuf)>, + #[allow(dead_code)] + tls_dir: Option, pub policy_local_ctx: Arc, } @@ -58,6 +68,11 @@ impl HostProxyHandle { pub const fn http_addr(&self) -> Option { self.proxy.http_addr() } + + #[must_use] + pub fn ca_file_paths(&self) -> Option<(PathBuf, PathBuf)> { + self.ca_file_paths.clone() + } } /// Start a host-side proxy for one sandbox. @@ -96,15 +111,93 @@ pub async fn start_host_proxy(config: HostProxyConfig) -> Result match SandboxCa::generate() { + Ok(ca) => { + let system_ca_bundle = read_system_ca_bundle(); + match write_ca_files(&ca, tls_dir.path(), &system_ca_bundle) { + Ok(paths) => match build_upstream_client_config(&system_ca_bundle) { + Ok(upstream_config) => { + let cert_cache = CertCache::new(ca); + let state = Arc::new(ProxyTlsState::new(cert_cache, upstream_config)); + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .state(StateId::Enabled, "enabled") + .message( + "Host proxy TLS termination enabled: ephemeral CA generated" + ) + .build() + ); + (Some(state), Some(paths), Some(tls_dir)) + } + Err(e) => { + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::High) + .status(StatusId::Failure) + .state(StateId::Disabled, "disabled") + .message(format!( + "Failed to build host proxy upstream TLS config, TLS termination disabled: {e}" + )) + .build() + ); + (None, None, Some(tls_dir)) + } + }, + Err(e) => { + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::High) + .status(StatusId::Failure) + .state(StateId::Disabled, "disabled") + .message(format!( + "Failed to write host proxy CA files, TLS termination disabled: {e}" + )) + .build() + ); + (None, None, Some(tls_dir)) + } + } + } + Err(e) => { + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::High) + .status(StatusId::Failure) + .state(StateId::Disabled, "disabled") + .message(format!( + "Failed to generate host proxy ephemeral CA, TLS termination disabled: {e}" + )) + .build() + ); + (None, None, Some(tls_dir)) + } + }, + Err(e) => { + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::High) + .status(StatusId::Failure) + .state(StateId::Disabled, "disabled") + .message(format!( + "Failed to create host proxy TLS trust directory, TLS termination disabled: {e}" + )) + .build() + ); + (None, None, None) + } + }; let proxy = ProxyHandle::start_with_bind_addr( &proxy_policy, Some(config.bind_addr), engine, Arc::new(ProxyIdentityMode::static_binary(config.binary_path)?), - // Host mode does not install a CA into the sandbox yet; L4 policy and - // plaintext/forward-proxy L7 paths are active, while HTTPS MITM is a - // follow-up once MXC has a trust-bootstrap story. - None, + tls_state, inference_ctx, config.provider_credentials, Some(policy_local_ctx.clone()), @@ -117,6 +210,8 @@ pub async fn start_host_proxy(config: HostProxyConfig) -> Result Date: Tue, 21 Jul 2026 21:35:55 -0500 Subject: [PATCH 20/31] fix(docs): remove outdated notes on governed egress from docs --- crates/openshell-driver-mxc/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index efe25affff..9d7965beb3 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -135,6 +135,5 @@ velocity keys not enabled, isolation_session absent). ## Deferred work - **Interactive exec/connect/forward** — gateway interactive-exec surgery (follow-on) -- **Governed egress polish** — gateway denial/activity bus wiring, broader real-MXC HTTPS L7 scenario coverage, and per-sandbox port allocation - **Restart durability** (deprovision orphaned sessions on startup) → follow-on - **GPU passthrough** → not pursued in host-side-governance design From 938bb6689900a8a289ef0e3afcfdbbb388cbff88 Mon Sep 17 00:00:00 2001 From: Akber Raza Date: Wed, 22 Jul 2026 17:18:52 -0500 Subject: [PATCH 21/31] fix(tests): update TLS environment variable paths to use temporary directory --- crates/openshell-driver-mxc/src/driver.rs | 31 ++++++++++--------- .../openshell-supervisor-network/src/proxy.rs | 4 +-- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index ee4c260b43..950dbf9587 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -1266,8 +1266,11 @@ mod lifecycle_tests { #[test] fn tls_env_vars_replace_user_trust_overrides() { - let ca_cert = PathBuf::from("C:\\openshell\\tls\\openshell-ca.pem"); - let bundle = PathBuf::from("C:\\openshell\\tls\\ca-bundle.pem"); + let tls_dir = std::env::temp_dir().join("openshell-mxc-tls-test"); + let ca_cert = tls_dir.join("openshell-ca.pem"); + let bundle = tls_dir.join("ca-bundle.pem"); + let ca_cert_path = ca_cert.display().to_string(); + let bundle_path = bundle.display().to_string(); let mut env = vec![ "FOO=bar".to_string(), "SSL_CERT_FILE=C:\\old\\bundle.pem".to_string(), @@ -1285,25 +1288,25 @@ mod lifecycle_tests { !env.iter() .any(|entry| entry == "node_extra_ca_certs=C:\\old\\ca.pem") ); - assert!( - env.contains(&"NODE_EXTRA_CA_CERTS=C:\\openshell\\tls\\openshell-ca.pem".to_string()) - ); - assert!(env.contains(&"DENO_CERT=C:\\openshell\\tls\\openshell-ca.pem".to_string())); - assert!(env.contains(&"SSL_CERT_FILE=C:\\openshell\\tls\\ca-bundle.pem".to_string())); - assert!(env.contains(&"REQUESTS_CA_BUNDLE=C:\\openshell\\tls\\ca-bundle.pem".to_string())); - assert!(env.contains(&"CURL_CA_BUNDLE=C:\\openshell\\tls\\ca-bundle.pem".to_string())); - assert!(env.contains(&"GIT_SSL_CAINFO=C:\\openshell\\tls\\ca-bundle.pem".to_string())); + assert!(env.contains(&format!("NODE_EXTRA_CA_CERTS={ca_cert_path}"))); + assert!(env.contains(&format!("DENO_CERT={ca_cert_path}"))); + assert!(env.contains(&format!("SSL_CERT_FILE={bundle_path}"))); + assert!(env.contains(&format!("REQUESTS_CA_BUNDLE={bundle_path}"))); + assert!(env.contains(&format!("CURL_CA_BUNDLE={bundle_path}"))); + assert!(env.contains(&format!("GIT_SSL_CAINFO={bundle_path}"))); } #[test] fn tls_readonly_grant_adds_ca_directory_once() { - let ca_cert = PathBuf::from("C:\\openshell\\tls\\openshell-ca.pem"); - let bundle = PathBuf::from("C:\\openshell\\tls\\ca-bundle.pem"); - let mut readonly = vec!["c:\\openshell\\tls".to_string()]; + let tls_dir = std::env::temp_dir().join("openshell-mxc-tls-test"); + let ca_cert = tls_dir.join("openshell-ca.pem"); + let bundle = tls_dir.join("ca-bundle.pem"); + let existing = tls_dir.display().to_string().to_ascii_lowercase(); + let mut readonly = vec![existing.clone()]; append_tls_readonly_grant(&mut readonly, Some(&(ca_cert, bundle))); - assert_eq!(readonly, vec!["c:\\openshell\\tls"]); + assert_eq!(readonly, vec![existing]); } #[test] diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 9ccb755672..5b6966428a 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -12343,15 +12343,15 @@ network_policies: const POLICY_REGO: &str = include_str!("../data/sandbox-policy.rego"); let exe = std::env::current_exe().expect("current_exe"); + let exe_yaml = serde_json::to_string(&exe.to_string_lossy()).expect("serialize exe path"); let data = format!( r#"network_policies: test_allow: name: test_allow endpoints: {endpoint_yaml} binaries: - - {{ path: "{exe}" }} + - {{ path: {exe_yaml} }} "#, - exe = exe.display(), ); let engine = Arc::new(OpaEngine::from_strings(POLICY_REGO, &data).expect("load policy")); From e891d4d4cfe6c6c702baab0b6bab4c7f540c61b8 Mon Sep 17 00:00:00 2001 From: Jamie King Date: Wed, 15 Jul 2026 18:57:57 -0600 Subject: [PATCH 22/31] fix(examples): make run-mxc-e2e harness correct and orphan-free The MXC e2e harness never actually exercised the fs scenarios: it started the gateway once and patched agent_command per scenario AFTERWARDS, so the running gateway kept launching the default demo agent (not shipped in the kit) and every fs scenario failed with CreateProcessW error:2. It also scored on the `sandbox create` exit code (non-zero due to the harmless interactive attach), wrote sandbox records to the persistent gateway DB (leaving orphans that collided on later runs), and its deny scenarios never proved denial. Changes: - Start a FRESH gateway per scenario so each scenario's agent_command is actually loaded (root cause of CreateProcessW error:2). - Score by on-disk artifact / expected outcome, not `sandbox create` exit. - Real deny assertions: a control write to a granted path must succeed (proves the agent ran) while the denied write must be absent. fs-empty probes an ungranted out-of-share path (share_dir is mapped rw by design). - Run the gateway on an ephemeral in-memory DB (sqlite::memory:) so the harness never writes to the persistent store and cannot leave orphan sandbox records; also use unique per-run sandbox names + pre-delete. - Fix the process_container probe: use a real cwd + absolute cmd.exe (canonical wxc-exec does not expand %TEMP% -> 0x8007010B). - Fix summary counts (@() so a single FAIL is counted and exit is non-zero). Verified PASS=4 FAIL=0 on 7F203-MXC-003 (no BaseContainer velocity keys) using a canonical wxc-exec build (AppContainer fallback). Signed-off-by: Akber Raza --- .../examples/run-mxc-e2e.ps1 | 200 ++++++++++-------- 1 file changed, 114 insertions(+), 86 deletions(-) diff --git a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 index 9f8baa3446..42c8d2a01f 100644 --- a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 +++ b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 @@ -3,10 +3,14 @@ # # 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. +# Starts one gateway and passes each scenario's command and working directory as +# create-time driver inputs. Emits per-scenario PASS/FAIL/SKIP(reason), prints a +# summary table, and exits non-zero only on FAIL. +# +# Scoring does not rely on `sandbox create` succeeding: the interactive attach +# can return non-zero after a healthy one-shot workload. Positive scenarios +# require their artifact, while deny scenarios require a successful control +# write and an absent denied write. # # PowerShell 5.1-compatible (no && / || / ternary operators). # @@ -22,17 +26,11 @@ # .\run-mxc-e2e.ps1 -Backend process_container -Scenario fs-rw # # Scenarios & expected verdicts: -# 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 filesystem policy; every write denied. -# processcontainer only (isolation_session has no deny -# primitive); skipped on isolation_session. -# network-reject - rw grant + network_policies rule; -# sandbox create must FAIL (invalid_argument). -# Runs on ANY backend including mock — never skips. +# fs-rw - in-policy write to DemoDir succeeds. +# fs-readonly - write to read-only dir is denied; control write succeeds. +# fs-default-deny - ungranted write is denied; control write succeeds. +# processcontainer only. +# network-reject - network_policies rule makes sandbox create fail. [CmdletBinding()] param( @@ -50,6 +48,9 @@ param( $ErrorActionPreference = "Stop" $PSNativeCommandUseErrorActionPreference = $false +try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {} +$OutputEncoding = [System.Text.Encoding]::UTF8 + $here = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path } function Step([string]$m) { Write-Host "`n=== $m ===" -ForegroundColor Cyan } @@ -59,6 +60,24 @@ 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 } +function Wait-File([string]$path, [int]$seconds) { + $deadline = (Get-Date).AddSeconds($seconds) + while ((Get-Date) -lt $deadline -and -not (Test-Path $path)) { + Start-Sleep -Milliseconds 400 + } + return (Test-Path $path) +} + +function Launch-Failed([string]$gwText) { + if ($null -eq $gwText) { return $false } + return ($gwText -match 'CreateProcessW failed error:2' ` + -or $gwText -match 'error:2' ` + -or $gwText -match 'exited -1' ` + -or $gwText -match 'The system cannot find the file' ` + -or $gwText -match 'E_NOTIMPL' ` + -or $gwText -match 'velocity') +} + # ── Pre-flight ──────────────────────────────────────────────────────────────── # In real mode, assert OPENSHELL_MXC_MOCK_WXC is NOT set. @@ -98,16 +117,20 @@ function Probe-Backend([string] $backendName, [string] $wxc) { } if ($backendName -eq "process_container") { + # wxc-exec treats config paths literally and does not expand %TEMP%. + # Use a real user-owned directory and an absolute executable path. + $probeDir = Join-Path $env:TEMP "mxc-e2e-probe" + New-Item -ItemType Directory -Force $probeDir | Out-Null $config = @{ version = "0.6.0-alpha" containerId = "e2e-probe-pc" containment = "processcontainer" process = @{ - commandLine = "cmd /c exit 0" - cwd = "%TEMP%" + commandLine = "C:\Windows\System32\cmd.exe /c exit 0" + cwd = $probeDir timeout = 10 } - filesystem = @{ readwritePaths = @("%TEMP%") } + filesystem = @{ readwritePaths = @($probeDir) } processContainer = @{ leastPrivilege = $false } } $json = $config | ConvertTo-Json -Depth 20 -Compress @@ -237,11 +260,14 @@ Ok "port $Port free" # ── Prepare DemoDir ─────────────────────────────────────────────────────────── Step "Prepare DemoDir $DemoDir" -New-Item -ItemType Directory -Force $DemoDir | Out-Null +$roSrc = "$DemoDir-ro-src" +$denyProbe = "$DemoDir-deny-probe" +New-Item -ItemType Directory -Force $DemoDir, $roSrc, $denyProbe | Out-Null Ok "DemoDir ready" $env:OPENSHELL_DRIVERS = "mxc" $env:OPENSHELL_MXC_SHARE_DIR = $DemoDir +$cmdExe = "C:\Windows\System32\cmd.exe" # ── Start gateway ───────────────────────────────────────────────────────────── @@ -251,13 +277,14 @@ $gwErrLog = "$gwLog.err" Remove-Item $gwLog, $gwErrLog -Force -ErrorAction SilentlyContinue $gw = Start-Process -FilePath $gateway ` - -ArgumentList @("--disable-tls", "--config", $toml, "--log-level", "info") ` + -ArgumentList @("--disable-tls", "--db-url", "sqlite::memory:", "--config", $toml, "--log-level", "info") ` -WorkingDirectory $here -PassThru -NoNewWindow ` -RedirectStandardOutput $gwLog -RedirectStandardError $gwErrLog Info "gateway pid $($gw.Id); logs: $gwLog" $results = @() +$runId = Get-Date -Format 'yyyyMMddHHmmss' try { # Wait for listening @@ -288,44 +315,42 @@ try { # ── 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) - # ExpectArtifact - whether the workload should create its target file - # Description - human-readable label + # Each scenario is positive, deny, or create-fail. Deny scenarios require + # both a successful write to a granted control path and an absent denied + # target, so an agent launch failure cannot be mistaken for enforcement. $allScenarios = @( @{ Name = "fs-rw" PolicyFile = Join-Path $policyDir "fs-rw.yaml" Backends = "both" - ExpectFail = $false - ExpectArtifact = $true + Kind = "positive" + PosTarget = Join-Path $DemoDir "fs-rw-result.txt" Description = "rw grant on DemoDir; in-policy write should succeed" }, @{ Name = "fs-readonly" PolicyFile = Join-Path $policyDir "fs-readonly.yaml" Backends = "both" - ExpectFail = $false - ExpectArtifact = $true - Description = "ro grant + rw share; write to ro dir should be denied" + Kind = "deny" + ControlTarget = Join-Path $DemoDir "fs-readonly-control.txt" + DenyTarget = Join-Path $roSrc "fs-readonly-deny.txt" + Description = "write to read-only dir denied; control write to rw dir succeeds" }, @{ Name = "fs-default-deny" PolicyFile = Join-Path $policyDir "fs-empty.yaml" Backends = "process_container" - ExpectFail = $false - ExpectArtifact = $false - Description = "empty filesystem policy; all writes denied (process_container only)" + Kind = "deny" + ControlTarget = Join-Path $DemoDir "fs-default-deny-control.txt" + DenyTarget = Join-Path $denyProbe "fs-default-deny-denied.txt" + Description = "empty policy; ungranted write denied; share control write succeeds" }, @{ Name = "network-reject" PolicyFile = Join-Path $policyDir "network-reject.yaml" Backends = "both" - ExpectFail = $true + Kind = "create-fail" Description = "network_policies rule causes sandbox create to fail (no live backend needed)" } ) @@ -345,9 +370,10 @@ try { Step "Scenario: $($sc.Name)" Info $sc.Description - # Backend gate: skip enforcement scenarios when backend not live (and not mock and not ExpectFail). + # Backend gate: create-fail validates translation and does not need a + # live backend. Enforcement scenarios do. $skipReason = $null - if (-not $sc.ExpectFail) { + if ($sc.Kind -ne "create-fail") { $backendMatches = ($sc.Backends -eq "both") -or ($sc.Backends -eq $Backend) if (-not $backendMatches) { $skipReason = "scenario requires backend=$($sc.Backends); current backend=$Backend" @@ -369,15 +395,24 @@ try { continue } - # 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('\', '/') + # Build the per-sandbox command. Commands and working directories are + # create-time inputs, so the gateway does not restart between scenarios. + if ($sc.Kind -eq "positive") { + Remove-Item $sc.PosTarget -Force -ErrorAction SilentlyContinue + $command = @($cmdExe, "/c", "echo ok 1> $($sc.PosTarget.Replace('\', '/'))") + } elseif ($sc.Kind -eq "deny") { + Remove-Item $sc.ControlTarget, $sc.DenyTarget -Force -ErrorAction SilentlyContinue + $control = $sc.ControlTarget.Replace('\', '/') + $denied = $sc.DenyTarget.Replace('\', '/') + $command = @($cmdExe, "/c", "echo ok 1> $control & echo denied 1> $denied") + } else { + $command = @($cmdExe, "/c", "exit 0") + } + $demoDirFwd = $DemoDir.Replace('\', '/') $driverConfig = @{ mxc = @{ - command = @("cmd", "/c", "echo.ok>$targetFwd") + command = $command cwd = $demoDirFwd } } | ConvertTo-Json -Compress -Depth 4 @@ -388,68 +423,61 @@ try { } else { $driverConfig } - # Run sandbox create. + $sandboxName = "$($sc.Name)-$runId" + try { & $cli sandbox delete $sandboxName 2>&1 | Out-Null } catch {} + + # Run sandbox create. Its exit status is only authoritative for the + # create-fail scenario; artifacts score workload scenarios. $createOut = $null $createExitCode = 0 try { - $createOut = & $cli sandbox create --name $sc.Name --policy $sc.PolicyFile --driver-config-json $driverConfigArg --no-tty 2>&1 + $createOut = & $cli sandbox create --name $sandboxName --policy $sc.PolicyFile --driver-config-json $driverConfigArg --no-tty 2>&1 $createExitCode = $LASTEXITCODE } catch { $createOut = $_.Exception.Message $createExitCode = 1 } $createOutStr = ($createOut -join "`n") - Info "create exit: $createExitCode" + Info "create exit: $createExitCode (not used for workload scoring)" # Delete sandbox (best-effort; no-op if create failed). - try { & $cli sandbox delete $sc.Name 2>&1 | Out-Null } catch {} + try { & $cli sandbox delete $sandboxName 2>&1 | Out-Null } catch {} + $gwText = (Get-Content $gwLog, $gwErrLog -Raw -ErrorAction SilentlyContinue) -join [Environment]::NewLine # Evaluate. - if ($sc.ExpectFail) { - # network-reject: create must fail. + if ($sc.Kind -eq "create-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" } } + } elseif ($sc.Kind -eq "positive") { + if (Wait-File $sc.PosTarget 30) { + Ok "$($sc.Name): in-policy write produced artifact" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "artifact present" } + } else { + Bad "$($sc.Name): artifact absent ($($sc.PosTarget))" + Info "createOut: $createOutStr" + if (Launch-Failed $gwText) { Info "gateway log shows an agent-launch failure" } + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "artifact absent" } + } } else { - # Wiring check in mock mode: the artifact must match the policy's - # expected outcome. In particular, default-deny passes only when - # the workload cannot create its target file. - if ($Mock) { - if ($sc.ExpectArtifact) { - $deadline = (Get-Date).AddSeconds(10) - while ((Get-Date) -lt $deadline -and -not (Test-Path $target)) { - Start-Sleep -Milliseconds 300 - } - } - $artifactExists = Test-Path $target - if ($artifactExists -eq $sc.ExpectArtifact) { - $outcome = if ($artifactExists) { "present" } else { "absent" } - Ok "$($sc.Name): artifact $outcome as expected (mock wiring OK)" - $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "mock wiring: artifact $outcome as expected" } - } else { - Bad "$($sc.Name): artifact outcome did not match policy (present=$artifactExists, expected=$($sc.ExpectArtifact))" - $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "mock wiring: artifact present=$artifactExists, expected=$($sc.ExpectArtifact)" } - } + $controlPresent = Wait-File $sc.ControlTarget 30 + $denyPresent = Test-Path $sc.DenyTarget + if ($controlPresent -and -not $denyPresent) { + Ok "$($sc.Name): control write succeeded; denied write correctly blocked" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "control present, deny absent" } + } elseif (-not $controlPresent) { + Bad "$($sc.Name): control write absent; denial result is inconclusive" + Info "createOut: $createOutStr" + if (Launch-Failed $gwText) { Info "gateway log shows an agent-launch failure" } + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "control absent (agent did not run)" } } 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)" } - } + Bad "$($sc.Name): denied write was not blocked" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "deny target present" } } } } @@ -469,9 +497,9 @@ try { 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 +$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" From df7852d0999295c4222379eb524689e3977ca89e Mon Sep 17 00:00:00 2001 From: Jamie King Date: Wed, 15 Jul 2026 19:13:54 -0600 Subject: [PATCH 23/31] fix(e2e): probe timeout is milliseconds (10ms->30000ms) MXC process.timeout is wall-clock ms (wire.rs). The 10 value meant 10ms, which the base-container tier (7F203-MXC-001/.181) enforced strictly and timed the probe out. AppContainer path (.18/-003) happened to slip under it. Bump to 30000ms so the process_container preflight probe is reliable across both tiers. Signed-off-by: Akber Raza --- crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 index 42c8d2a01f..aeaab12683 100644 --- a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 +++ b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 @@ -128,7 +128,7 @@ function Probe-Backend([string] $backendName, [string] $wxc) { process = @{ commandLine = "C:\Windows\System32\cmd.exe /c exit 0" cwd = $probeDir - timeout = 10 + timeout = 30000 # MXC process.timeout is milliseconds } filesystem = @{ readwritePaths = @($probeDir) } processContainer = @{ leastPrivilege = $false } From 122b77ce594a924d8aa60a98d37e5b42604d3efa Mon Sep 17 00:00:00 2001 From: Shailendra Singh Date: Tue, 21 Jul 2026 14:24:05 -0700 Subject: [PATCH 24/31] fix(mxc): use native paths in real runtime probes Signed-off-by: Shailendra Singh Signed-off-by: Akber Raza --- crates/openshell-driver-mxc/README.md | 4 + .../examples/probe-mxc-host.ps1 | 24 ++- .../tests/wxc_exec_real.rs | 178 +++++++++--------- 3 files changed, 113 insertions(+), 93 deletions(-) diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 9d7965beb3..ff06b5e654 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -120,6 +120,10 @@ exits 0 rather than failing. and a `verdicts` object). Run it before the real-MXC lane to understand what will PASS vs SKIP on a given host: +The probe uses a unique, user-owned Windows temp directory for every run. +MXC treats config paths literally (it does not expand `%TEMP%`), and the +per-run directory keeps AppContainer+DACL fallback mutations narrowly scoped. + ```powershell powershell -NoProfile -ExecutionPolicy Bypass ` -File crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 diff --git a/crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 b/crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 index 4a20e7b86c..343e88e88d 100644 --- a/crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 +++ b/crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 @@ -119,6 +119,10 @@ $pcTrialMessage = "wxc-exec not found" $isoTrialResult = "absent" $isoTrialMessage = "wxc-exec not found" +$probeTempPath = Join-Path ([System.IO.Path]::GetTempPath()) ("openshell-mxc-probe-" + [Guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Path $probeTempPath | Out-Null + +try { if ($wxcInfo.exists) { # --probe $probeResult = Invoke-WxcProbe -wxc $WxcExecPath @@ -131,11 +135,11 @@ if ($wxcInfo.exists) { containment = "processcontainer" process = @{ commandLine = "cmd /c exit 0" - cwd = "%TEMP%" + cwd = $probeTempPath timeout = 0 } filesystem = @{ - readwritePaths = @("%TEMP%") + readwritePaths = @($probeTempPath) } } $dryResult = Invoke-WxcDryRun -wxc $WxcExecPath -config $dryConfig @@ -149,15 +153,22 @@ if ($wxcInfo.exists) { containment = "processcontainer" process = @{ commandLine = "cmd /c exit 0" - cwd = "%TEMP%" - timeout = 10 + cwd = $probeTempPath + timeout = 30000 } filesystem = @{ - readwritePaths = @("%TEMP%") + readwritePaths = @($probeTempPath) } processContainer = @{ leastPrivilege = $false } + # cmd.exe needs the Win32k calls represented by disable = false. + # Filesystem and network restrictions remain default-deny. + ui = @{ + disable = $false + clipboard = "none" + injection = $false + } } $pcResult = Invoke-WxcPhase -wxc $WxcExecPath -config $pcConfig $pcOutput = $pcResult.Output @@ -260,6 +271,9 @@ if ($wxcInfo.exists) { $isoTrialMessage = "exit $($isoResult.ExitCode): $isoOutput" } } +} finally { + Remove-Item -LiteralPath $probeTempPath -Recurse -Force -ErrorAction SilentlyContinue +} # ── Verdicts ────────────────────────────────────────────────────────────────── diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs index dd16836e4b..a53d1b5ef9 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -15,9 +15,9 @@ //! Two families: //! //! **(a) Dry-run contract tests** — exercise `--dry-run` only; pass/fail on -//! 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. +//! 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 @@ -31,6 +31,7 @@ #![cfg(target_os = "windows")] use base64::Engine as _; +use openshell_core::proto::{FilesystemPolicy, SandboxPolicy}; use std::path::PathBuf; use std::process::Command; @@ -59,6 +60,17 @@ fn wxc_path() -> Option { None } +/// Create a real, user-owned Windows directory for MXC filesystem grants. +/// +/// MXC config values are literal paths: it does not expand `%TEMP%`. A unique +/// directory also keeps AppContainer+DACL fallback mutations scoped to test +/// data the current user owns. +fn temp_fixture() -> (tempfile::TempDir, String) { + let dir = tempfile::tempdir().expect("create MXC temp fixture"); + let path = dir.path().to_string_lossy().into_owned(); + (dir, path) +} + // ── Dry-run helper ──────────────────────────────────────────────────────────── /// Invoke `wxc-exec --config-base64 --dry-run` synchronously. @@ -94,19 +106,18 @@ fn dryrun_accepts_minimal_processcontainer_config() { return; }; - let tmpdir = tempfile::tempdir().expect("tempdir"); - let tmpdir_str = tmpdir.path().to_string_lossy().into_owned(); + let (_tempdir, temp_path) = temp_fixture(); let config = serde_json::json!({ "version": "0.6.0-alpha", "containerId": "test-minimal", "containment": "processcontainer", "process": { "commandLine": "cmd /c exit 0", - "cwd": tmpdir_str, + "cwd": temp_path, "timeout": 0, }, "filesystem": { - "readwritePaths": [tmpdir_str], + "readwritePaths": [temp_path], }, }); @@ -126,19 +137,18 @@ 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 (_tempdir, temp_path) = temp_fixture(); let config = serde_json::json!({ "version": "0.6.0-alpha", "containerId": "test-net-block", "containment": "processcontainer", "process": { "commandLine": "cmd /c exit 0", - "cwd": tmpdir_str, + "cwd": temp_path, "timeout": 0, }, "filesystem": { - "readwritePaths": [tmpdir_str], + "readwritePaths": [temp_path], }, "network": { "defaultPolicy": "block", @@ -165,19 +175,18 @@ fn dryrun_accepts_localhost_proxy_shape() { return; }; - let tmpdir = tempfile::tempdir().expect("tempdir"); - let tmpdir_str = tmpdir.path().to_string_lossy().into_owned(); + let (_tempdir, temp_path) = temp_fixture(); let config = serde_json::json!({ "version": "0.6.0-alpha", "containerId": "test-proxy-localhost", "containment": "processcontainer", "process": { "commandLine": "cmd /c exit 0", - "cwd": tmpdir_str, + "cwd": temp_path, "timeout": 0, }, "filesystem": { - "readwritePaths": [tmpdir_str], + "readwritePaths": [temp_path], }, "network": { "defaultPolicy": "block", @@ -205,17 +214,18 @@ fn dryrun_rejects_host_port_proxy_shape() { return; }; + let (_tempdir, temp_path) = temp_fixture(); let config = serde_json::json!({ "version": "0.6.0-alpha", "containerId": "test-proxy-hostport", "containment": "processcontainer", "process": { "commandLine": "cmd /c exit 0", - "cwd": "%TEMP%", + "cwd": temp_path, "timeout": 0, }, "filesystem": { - "readwritePaths": ["%TEMP%"], + "readwritePaths": [temp_path], }, "network": { "defaultPolicy": "block", @@ -243,17 +253,18 @@ fn dryrun_rejects_unknown_containment() { return; }; + let (_tempdir, temp_path) = temp_fixture(); let config = serde_json::json!({ "version": "0.6.0-alpha", "containerId": "test-bad-containment", "containment": "nonsense", "process": { "commandLine": "cmd /c exit 0", - "cwd": "%TEMP%", + "cwd": temp_path, "timeout": 0, }, "filesystem": { - "readwritePaths": ["%TEMP%"], + "readwritePaths": [temp_path], }, }); @@ -261,10 +272,9 @@ fn dryrun_rejects_unknown_containment() { 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. +/// The most important dry-run test: build a typed Windows policy, run +/// `split_policy` (proxy_redirect 127.0.0.1:18080, containment +/// "processcontainer"), and verify the resulting config with `--dry-run`. /// /// This proves that the mapper's emitted JSON is accepted by the real binary — /// the central contract of the policy-mapper integration. @@ -276,33 +286,28 @@ fn dryrun_accepts_split_policy_output() { 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 (_tempdir, temp_path) = temp_fixture(); + let policy = SandboxPolicy { + filesystem: Some(FilesystemPolicy { + include_workdir: false, + read_only: Vec::new(), + read_write: vec![temp_path.clone()], + }), + ..Default::default() + }; let opts = openshell_driver_mxc::MxcMappingOptions { containment: "processcontainer".to_string(), + command: "cmd /c exit 0".to_string(), + container_id: "split-policy-dryrun".to_string(), + cwd: Some(temp_path), 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. + // There should be no error losses on the processcontainer split path. let error_losses: Vec<_> = result .loss .iter() @@ -316,29 +321,7 @@ 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": tmpdir_str, - "timeout": 0, - }); - // containerId is also required for processcontainer. - mxc_config["containerId"] = serde_json::json!("split-policy-dryrun"); + let mxc_config = result.mxc_config; let (code, stdout, stderr) = dry_run(&wxc, &mxc_config); assert_eq!( @@ -358,32 +341,39 @@ fn dryrun_accepts_split_policy_output() { /// Probe the processcontainer backend. /// -/// 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 +/// Runs a trivial one-shot (`cmd /c exit 0`, user-owned 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").is_ok_and(|value| value == "1") { + 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 tmpdir = tempfile::tempdir().map_err(|error| format!("tempdir failed: {error}"))?; - let tmpdir_str = tmpdir.path().to_string_lossy().into_owned(); + let (_tempdir, temp_path) = temp_fixture(); let config = serde_json::json!({ "version": "0.6.0-alpha", "containerId": "probe-pc", "containment": "processcontainer", "process": { "commandLine": "cmd /c exit 0", - "cwd": tmpdir_str, - "timeout": 10, + "cwd": temp_path, + "timeout": 30_000, }, "filesystem": { - "readwritePaths": [tmpdir_str], + "readwritePaths": [temp_path], + }, + "ui": { + "disable": false, + "clipboard": "none", + "injection": false, }, }); @@ -406,17 +396,16 @@ fn probe_processcontainer(wxc: &PathBuf) -> Result<(), String> { || combined.contains("not enabled") { // Extract the message if possible for a more useful skip reason. - let reason = + let reason = if let Ok(v) = serde_json::from_str::(&String::from_utf8_lossy(&out.stdout)) - .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() - }, - ); + { + 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); } @@ -432,12 +421,15 @@ 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").is_ok_and(|value| value == "1") { + 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(), @@ -586,7 +578,7 @@ fn pc_oneshot_in_policy_write_succeeds() { "process": { "commandLine": format!("cmd /c echo hello > \"{target_str}\""), "cwd": tmpdir_str, - "timeout": 30, + "timeout": 30_000, }, "filesystem": { "readwritePaths": [tmpdir_str], @@ -594,6 +586,11 @@ fn pc_oneshot_in_policy_write_succeeds() { "processContainer": { "leastPrivilege": false, }, + "ui": { + "disable": false, + "clipboard": "none", + "injection": false, + }, }); let json = serde_json::to_string(&config).unwrap(); @@ -620,7 +617,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"] @@ -648,7 +645,7 @@ fn pc_oneshot_out_of_policy_write_denied() { "process": { "commandLine": format!("cmd /c echo denied > \"{denied_file_str}\""), "cwd": granted_str, - "timeout": 30, + "timeout": 30_000, }, "filesystem": { // Only the granted_dir is in policy — denied_dir is NOT granted. @@ -657,6 +654,11 @@ fn pc_oneshot_out_of_policy_write_denied() { "processContainer": { "leastPrivilege": false, }, + "ui": { + "disable": false, + "clipboard": "none", + "injection": false, + }, }); let json = serde_json::to_string(&config).unwrap(); @@ -685,7 +687,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] From 5d5956e9049c0c53405de867cd565d01f52d6e82 Mon Sep 17 00:00:00 2001 From: Prashant Khodade Date: Wed, 12 Aug 2026 14:55:17 +0200 Subject: [PATCH 25/31] fix(mxc): make processcontainer work with mxc-latest-released wxc-exec Three fixes to support the release wxc-exec binary (BaseContainer dispatcher) in addition to mxc-fixes-env-vars: 1. Seed process env from host (driver.rs) ProcessContainer starts with a completely blank environment -- no PATH, SystemRoot, or anything. Seed the process env from the gateway host environment so the agent binary can locate DLLs and run. Skip internal Windows drive-letter variables (keys starting with '=') which cause CreateProcessW to return ERROR_ENVVAR_NOT_FOUND. User agent_env entries and TLS CA vars are applied as overrides on top of the host env. 2. Remove TLS readonly_paths grant (driver.rs) The release wxc-exec (BaseContainer dispatcher) requires write-DAC permission on every path in readonly_paths to set up AppContainer ACLs. Adding the proxy's temp TLS directory caused a DACL error and exit -1. The CA cert paths remain available to the agent via TLS env vars. 3. Remove allowedHosts from network JSON (mxc.rs) The release wxc-exec rejects network.allowedHosts / network.blockedHosts on Windows with "not yet supported". Removed the loopback exemption attempt (127.0.0.1, ::1, localhost) from the network section. Intra-container loopback works natively in the release binary without it -- the spawner can connect to the server at 127.0.0.1:22000 directly. Additional changes: - mxc-ws-agent.rs: add relay-debug.txt error capture and relay-ready.txt marker for reliable timing of host client connections. - mxc-ws-gateway.toml: debug = true for JSON config dump during diagnosis. - run-ws-agent-test.ps1: default port changed to 17670 (gateway default); relay-ready.txt polling before ws-echo to avoid connecting before the spawner has established the proxy bridge. Signed-off-by: Prashant Khodade Signed-off-by: Akber Raza --- crates/openshell-driver-mxc/README.md | 11 ++- crates/openshell-driver-mxc/src/driver.rs | 97 +++++++++---------- crates/openshell-driver-mxc/src/mxc.rs | 20 ++-- .../src/policy_map/map.rs | 21 ++-- .../tests/policy_mapper_examples.rs | 9 +- .../tests/policy_mapper_matrix.rs | 17 ++-- .../tests/wxc_exec_real.rs | 6 -- 7 files changed, 77 insertions(+), 104 deletions(-) diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index ff06b5e654..c7f3221bb0 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -83,9 +83,14 @@ redirect, and the driver starts a host CONNECT proxy from the trimmed network-only `SandboxPolicy`. The proxy uses the configured agent command as the static sandbox process identity because MXC does not expose Linux-style procfs socket ownership. For HTTPS L7 inspection, the host proxy generates a -per-sandbox CA, grants the CA directory read-only in MXC, and injects -`NODE_EXTRA_CA_CERTS`, `DENO_CERT`, `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`, -`CURL_CA_BUNDLE`, and `GIT_SSL_CAINFO` into the agent process env. The +per-sandbox CA and injects `NODE_EXTRA_CA_CERTS`, `DENO_CERT`, `SSL_CERT_FILE`, +`REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, and `GIT_SSL_CAINFO` into the agent +process env. It does not add the generated CA directory to MXC read-only grants: +released `wxc-exec` BaseContainer builds require `WRITE_DAC` on every such +grant and reject the user-owned proxy temp directory. The driver seeds only +`SYSTEMROOT`, `WINDIR`, `PATH`, `COMSPEC`, and `LOCALAPPDATA` from the gateway +host before applying sandbox and TLS overrides, so required Windows bootstrap +values remain available without exposing the gateway's full environment. 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. diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 950dbf9587..c2d6f2318a 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -248,15 +248,30 @@ fn sandbox_config(sandbox: &DriverSandbox) -> Result 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()); + // Released wxc-exec ProcessContainer builds start from the explicit + // process environment. Seed only the non-secret Windows bootstrap values; + // copying the gateway's full environment would leak unrelated host secrets + // into untrusted sandbox workloads. + let mut environment = MINIMAL_WINDOWS_BOOTSTRAP_ENV + .iter() + .filter_map(|key| { + std::env::var(key) + .ok() + .map(|value| ((*key).to_string(), value)) + }) + .collect::>(); + if let Some(spec) = sandbox.spec.as_ref() { + if let Some(template) = spec.template.as_ref() { + environment.extend(template.environment.clone()); + } + environment.extend(spec.environment.clone()); + } let mut environment = environment .into_iter() .map(|(key, value)| format!("{key}={value}")) @@ -376,25 +391,6 @@ fn append_tls_env_vars(env: &mut Vec, ca_paths: Option<&(PathBuf, PathBu ]); } -fn append_tls_readonly_grant( - readonly_paths: &mut Vec, - ca_paths: Option<&(PathBuf, PathBuf)>, -) { - let Some((ca_cert_path, _)) = ca_paths else { - return; - }; - let Some(dir) = ca_cert_path.parent() else { - return; - }; - let dir = dir.display().to_string(); - if !readonly_paths - .iter() - .any(|existing| existing.eq_ignore_ascii_case(&dir)) - { - readonly_paths.push(dir); - } -} - impl MxcComputeBackend { pub fn new(config: MxcComputeConfig) -> Self { let invoker = WxcExecInvoker::new(&config.wxc_exec_path, config.debug); @@ -858,8 +854,11 @@ async fn run_lifecycle( )); } - let mut readonly_paths = mapped.readonly_paths; - append_tls_readonly_grant(&mut readonly_paths, host_proxy_ca_paths.as_ref()); + // Do not add the generated TLS directory to readonly_paths. Released + // wxc-exec BaseContainer builds require WRITE_DAC on every read-only grant; + // the user-owned proxy temp directory otherwise makes sandbox launch fail. + // CA paths remain available through the TLS trust environment variables. + let readonly_paths = mapped.readonly_paths; let filesystem = MxcFilesystem { readwrite_paths: mapped.readwrite_paths, readonly_paths, @@ -870,6 +869,7 @@ async fn run_lifecycle( let command_line = encode_windows_command_line(&sandbox_config.command); let mut environment = sandbox_environment(&sandbox); append_tls_env_vars(&mut environment, host_proxy_ca_paths.as_ref()); + info!(sandbox = %sandbox_name, count = environment.len(), "MXC process env vars"); let process = MxcProcess { command_line: command_line.clone(), cwd: sandbox_config.cwd, @@ -1248,7 +1248,7 @@ mod lifecycle_tests { } #[test] - fn sandbox_environment_uses_sandbox_scope_with_spec_precedence() { + fn sandbox_environment_inherits_host_with_spec_precedence() { let mut sandbox = driver_sandbox("sb-env"); let spec = sandbox.spec.as_mut().unwrap(); spec.template @@ -1258,10 +1258,18 @@ mod lifecycle_tests { .insert("SHARED".into(), "template".into()); spec.environment.insert("SHARED".into(), "spec".into()); spec.environment.insert("TOKEN".into(), "value".into()); - assert_eq!( - sandbox_environment(&sandbox), - vec!["SHARED=spec".to_string(), "TOKEN=value".to_string()] - ); + let environment = sandbox_environment(&sandbox); + assert!(environment.contains(&"SHARED=spec".to_string())); + assert!(environment.contains(&"TOKEN=value".to_string())); + for key in MINIMAL_WINDOWS_BOOTSTRAP_ENV { + if let Ok(value) = std::env::var(key) { + assert!(environment.contains(&format!("{key}={value}"))); + } + } + assert!(environment.iter().all(|entry| { + let key = entry.split_once('=').map_or(entry.as_str(), |(key, _)| key); + key == "SHARED" || key == "TOKEN" || MINIMAL_WINDOWS_BOOTSTRAP_ENV.contains(&key) + })); } #[test] @@ -1296,19 +1304,6 @@ mod lifecycle_tests { assert!(env.contains(&format!("GIT_SSL_CAINFO={bundle_path}"))); } - #[test] - fn tls_readonly_grant_adds_ca_directory_once() { - let tls_dir = std::env::temp_dir().join("openshell-mxc-tls-test"); - let ca_cert = tls_dir.join("openshell-ca.pem"); - let bundle = tls_dir.join("ca-bundle.pem"); - let existing = tls_dir.display().to_string().to_ascii_lowercase(); - let mut readonly = vec![existing.clone()]; - - append_tls_readonly_grant(&mut readonly, Some(&(ca_cert, bundle))); - - assert_eq!(readonly, vec![existing]); - } - #[test] fn windows_command_line_preserves_argument_boundaries() { assert_eq!( @@ -1496,12 +1491,8 @@ mod lifecycle_tests { 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!(recorded["network"].get("allowedHosts").is_none()); + assert!(recorded["network"].get("blockedHosts").is_none()); // MXC 0.6.0-alpha accepts only {"proxy": {"localhost": N}}. let proxy_port = recorded["network"]["proxy"]["localhost"] .as_u64() diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs index 9ca165df7b..0a77565f83 100644 --- a/crates/openshell-driver-mxc/src/mxc.rs +++ b/crates/openshell-driver-mxc/src/mxc.rs @@ -99,10 +99,10 @@ fn network_json(network: &MxcNetwork) -> serde_json::Value { // 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. + // Released wxc-exec rejects allowedHosts and blockedHosts as unsupported, + // even when they are empty. The host proxy enforces the L7 allowlist. 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!({ "localhost": proxy.port() }); @@ -758,18 +758,8 @@ mod tests { 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!(config["network"].get("allowedHosts").is_none()); + assert!(config["network"].get("blockedHosts").is_none()); // MXC 0.6.0-alpha accepts only {"proxy": {"localhost": N}}. assert_eq!(config["network"]["proxy"]["localhost"], 18080); assert!( @@ -792,6 +782,8 @@ mod tests { proxy: Some("127.0.0.1:18080".parse().unwrap()), }; let value = network_json(&network); + assert!(value.get("allowedHosts").is_none()); + assert!(value.get("blockedHosts").is_none()); assert_eq!(value["proxy"]["localhost"], 18080); assert!(value["proxy"].get("host").is_none()); assert!(value["proxy"].get("port").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 7d9025e5a6..c9030f9664 100644 --- a/crates/openshell-driver-mxc/src/policy_map/map.rs +++ b/crates/openshell-driver-mxc/src/policy_map/map.rs @@ -75,9 +75,9 @@ pub struct MxcMappingResult { 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. + /// Direct egress is blocked at the MXC layer. Unsupported host-list fields + /// are omitted; all outbound connections flow through the proxy, which + /// 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 @@ -99,9 +99,9 @@ pub fn map_to_mxc(policy: &SandboxPolicy, opts: &MxcMappingOptions) -> MxcMappin /// `OpenShell` CONNECT proxy. /// /// The returned [`SplitPolicyResult::mxc_config`] sets `network.proxy` to -/// `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 +/// `opts.proxy_redirect` and omits unsupported host-list fields. 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. /// @@ -172,7 +172,8 @@ 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. + // Released wxc-exec rejects allowedHosts and blockedHosts as unsupported, + // even when empty, so the proxy path omits both fields. // // MXC 0.6.0-alpha schema accepts ONLY {"proxy": {"localhost": }}. // {"host": ..., "port": ...} and every other shape is rejected — verified @@ -191,11 +192,7 @@ fn build_split_mxc_config( "The redirect cannot be emitted; use a 127.0.0.1:PORT address.", ); } - let mut network = json!({ - "defaultPolicy": "block", - "allowedHosts": [], - "blockedHosts": [], - }); + let mut network = json!({ "defaultPolicy": "block" }); if proxy_supported && proxy_addr.ip() == std::net::IpAddr::from([127, 0, 0, 1]) { network["proxy"] = json!({ "localhost": proxy_addr.port() }); } diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs b/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs index 4d3a3cfa5c..68b4f90098 100644 --- a/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs +++ b/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs @@ -297,12 +297,11 @@ fn split_policy_routes_network_to_proxy() { "non-127.0.0.1 redirect must produce an error loss item" ); - // Direct egress is blocked; allowedHosts is empty (proxy enforces the list). + // Direct egress is blocked; unsupported host-list fields are omitted and + // the proxy enforces the full list. assert_eq!(cfg["network"]["defaultPolicy"], "block"); - assert!( - str_list(&cfg["network"]["allowedHosts"]).is_empty(), - "split path must not populate allowedHosts" - ); + assert!(cfg["network"].get("allowedHosts").is_none()); + assert!(cfg["network"].get("blockedHosts").is_none()); // Filesystem grants are preserved unchanged. assert_eq!( diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs index 39a94bee9b..592c682250 100644 --- a/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs +++ b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs @@ -393,11 +393,9 @@ fn a_split_proxy_localhost_port() { 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" - ); + // Released wxc-exec rejects host-list fields, even when empty. + assert!(result.mxc_config["network"].get("allowedHosts").is_none()); + assert!(result.mxc_config["network"].get("blockedHosts").is_none()); } // ─── QUADRANT B: OpenShell features MXC cannot express ────────────────────── @@ -1011,7 +1009,7 @@ fn c_empty_policy_default_deny_posture() { ); } -/// Split path with network rules present: allowedHosts stays empty. +/// Split path with network rules present: unsupported host lists stay absent. #[test] fn c_split_empty_allowed_hosts_with_network_rules() { let mut policy = SandboxPolicy::default(); @@ -1027,11 +1025,8 @@ fn c_split_empty_allowed_hosts_with_network_rules() { }, ); 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"] - ); + assert!(result.mxc_config["network"].get("allowedHosts").is_none()); + assert!(result.mxc_config["network"].get("blockedHosts").is_none()); // But proxy redirect is present. assert_eq!( result.mxc_config["network"]["proxy"]["localhost"], 18080, diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs index a53d1b5ef9..0e65b7f486 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -152,8 +152,6 @@ fn dryrun_accepts_network_block_without_proxy() { }, "network": { "defaultPolicy": "block", - "allowedHosts": [], - "blockedHosts": [], }, }); @@ -190,8 +188,6 @@ fn dryrun_accepts_localhost_proxy_shape() { }, "network": { "defaultPolicy": "block", - "allowedHosts": [], - "blockedHosts": [], "proxy": { "localhost": 18080 }, }, }); @@ -229,8 +225,6 @@ fn dryrun_rejects_host_port_proxy_shape() { }, "network": { "defaultPolicy": "block", - "allowedHosts": [], - "blockedHosts": [], // MXC 0.6.0-alpha rejects {"host","port"} — verified empirically. "proxy": { "host": "127.0.0.1", "port": 18080 }, }, From 7be6327650f2c1f468fd6f4955b1bccd75cff741 Mon Sep 17 00:00:00 2001 From: Jamie King Date: Wed, 15 Jul 2026 21:52:53 -0600 Subject: [PATCH 26/31] fix(e2e): address CodeRabbit review on run-mxc-e2e.ps1 (MR !46) Four robustness/correctness fixes from CodeRabbit: 1. Start-Gw: kill the spawned gateway before the "did not start within 30s" throw. If the process is alive but never binds the port, $gw is not yet assigned in the caller, so the finally block cannot reap it -> orphan gateway holding the port for the next run. 2. create-fail scoring: a non-zero `sandbox create` exit alone is not proof of a policy rejection (gateway-registration/transport/fixture errors also exit non-zero and would false-pass). PASS now requires a genuine rejection signal (network / invalid_argument / network_policies) AND that it is not an infrastructure failure; other non-zero exits go to FAIL with output captured. 3. deny scenarios (ControlTarget path): snapshot the deny target AFTER Wait-File lands the control artifact, so a late denied write (enforcement regression racing the control write) can no longer be recorded as PASS. 4. -KeepRunning: break out of the scenario loop after the first scenario so a later scenario does not start a second gateway on the same port (previously a reliable port collision instead of a usable debug mode). Re-verified PASS=4 FAIL=0 on both boxes (7F203-MXC-001 base-container and 7F203-MXC-003 AppContainer fallback); network-policy-rejected correctly scores as "policy rejection". Signed-off-by: Akber Raza --- .../examples/run-mxc-e2e.ps1 | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 index aeaab12683..1776274503 100644 --- a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 +++ b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 @@ -446,9 +446,25 @@ try { # Evaluate. if ($sc.Kind -eq "create-fail") { - if ($createExitCode -ne 0) { - Ok "$($sc.Name): create correctly failed (exit $createExitCode)" - $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "create failed as expected" } + # A non-zero exit alone is NOT sufficient: gateway-registration, + # transport, or malformed-fixture errors also exit non-zero and would + # false-pass this scenario. Require a genuine policy-rejection signal + # and confirm it is not an infrastructure failure. + $rejected = ($createOutStr -match '(?i)network' ` + -or $createOutStr -match '(?i)invalid[_ -]?argument' ` + -or $createOutStr -match '(?i)policy' ` + -or $gwText -match '(?i)network_policies') + $infraFail = ($createOutStr -match '(?i)connection refused' ` + -or $createOutStr -match '(?i)not registered' ` + -or $createOutStr -match '(?i)transport error' ` + -or $createOutStr -match '(?i)failed to connect') + if ($createExitCode -ne 0 -and $rejected -and -not $infraFail) { + Ok "$($sc.Name): create correctly rejected by policy (exit $createExitCode)" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "policy rejection" } + } elseif ($createExitCode -ne 0) { + Bad "$($sc.Name): create failed but not with a policy-rejection signal (possible harness/infra error)" + Info "output: $createOutStr" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "non-rejection failure" } } else { Bad "$($sc.Name): create succeeded but should have failed" Info "output: $createOutStr" @@ -466,6 +482,8 @@ try { } } else { $controlPresent = Wait-File $sc.ControlTarget 30 + # Snapshot the deny target only after the control artifact lands so a + # late denied write cannot be recorded as a pass. $denyPresent = Test-Path $sc.DenyTarget if ($controlPresent -and -not $denyPresent) { Ok "$($sc.Name): control write succeeded; denied write correctly blocked" From d1ac66bd7b5cd0fd5cfd71dac44fdf5be82e2417 Mon Sep 17 00:00:00 2001 From: Prashant S Khodade Date: Thu, 16 Jul 2026 21:06:57 +0530 Subject: [PATCH 27/31] feat(mxc-e2e): collect run-mxc-e2e output into a results bundle Mirror the sibling run-*.ps1 scripts by collecting every run's logs into a timestamped results-e2e-\ folder and zipping it. The bundle contains the console transcript, per-scenario gateway stdout/stderr, the exact TOML rendered for each scenario, the policy fixture used, and a summary.txt with the verdict table. Per-scenario gateway logs now land in gateway..log/.err.log inside the bundle instead of a single fixed gateway.e2e.log in the script directory. Wrap pre-flight, mode setup, scenario definitions, and the scenario loop in a single try/catch/finally so the finally always writes the summary, stops the transcript, and zips the bundle -- even on a pre-flight failure. The existing per-scenario gateway-cleanup try/finally stays nested inside. All scenario logic, scoring rules, and comments are preserved. Signed-off-by: Prashant S Khodade Signed-off-by: Akber Raza --- .../examples/run-mxc-e2e.ps1 | 759 ++++++++++-------- 1 file changed, 426 insertions(+), 333 deletions(-) diff --git a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 index 1776274503..b16c1662a9 100644 --- a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 +++ b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 @@ -1,29 +1,33 @@ -# 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. # -# Starts one gateway and passes each scenario's command and working directory as -# create-time driver inputs. Emits per-scenario PASS/FAIL/SKIP(reason), prints a -# summary table, and exits non-zero only on FAIL. +# Runs a table of policy scenarios against the OpenShell MXC driver, emits +# per-scenario PASS/FAIL/SKIP(reason), prints a summary table, and exits non-zero +# only on FAIL. # -# Scoring does not rely on `sandbox create` succeeding: the interactive attach -# can return non-zero after a healthy one-shot workload. Positive scenarios -# require their artifact, while deny scenarios require a successful control -# write and an absent denied write. +# Every run collects its logs into a timestamped results-e2e-\ folder and +# zips it (mirrors the sibling run-*.ps1 scripts). The bundle contains the console +# transcript, the per-scenario gateway stdout/stderr, the exact TOML rendered for +# each scenario, the policy fixture used, and a summary.txt with the verdict table. # -# PowerShell 5.1-compatible (no && / || / ternary operators). +# The gateway restarts per scenario to keep logs and the in-memory database +# isolated. Workload command/cwd are supplied per sandbox through +# --driver-config-json; they are never patched into gateway configuration. # -# Usage examples: +# Scoring (why we do NOT gate on `sandbox create` exit code): +# The ground truth is the on-disk artifact, so positive scenarios pass on +# artifact PRESENT and deny +# scenarios pass on the denied write being ABSENT while a CONTROL write (to a +# granted path) is PRESENT -- which proves the agent actually ran. # -# # Real mode (probe-gated — backends that are absent are SKIPped): -# powershell -NoProfile -ExecutionPolicy Bypass -File .\run-mxc-e2e.ps1 +# PowerShell 5.1-compatible (no && / || / ternary operators). ASCII only. # -# # 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 +# Usage: +# powershell -NoProfile -ExecutionPolicy Bypass -File .\run-mxc-e2e.ps1 -WxcExecPath C:\mxc-kit\bin\wxc-exec.exe +# .\run-mxc-e2e.ps1 -Mock # wiring-only, no real backend +# .\run-mxc-e2e.ps1 -Scenario fs-rw # single scenario # # Scenarios & expected verdicts: # fs-rw - in-policy write to DemoDir succeeds. @@ -53,6 +57,15 @@ $OutputEncoding = [System.Text.Encoding]::UTF8 $here = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path } +# --- Results bundle ----------------------------------------------------------- +# Collect every log + the exact rendered config per scenario into a timestamped +# results\ folder, then zip it (mirrors the sibling run-*.ps1 scripts). Created up +# front so the transcript captures the whole run, including pre-flight failures. +$stamp = Get-Date -Format "yyyyMMdd-HHmmss" +$resultDir = Join-Path $here "results-e2e-$stamp" +New-Item -ItemType Directory -Force $resultDir | Out-Null +Start-Transcript -Path (Join-Path $resultDir "transcript.txt") -Force | Out-Null + 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 } @@ -60,6 +73,90 @@ 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 } +# Double backslashes so a Windows path is a valid TOML/JSON basic-string element. +function Esc([string]$p) { return $p.Replace('\', '\\') } + +# --- Path variables ----------------------------------------------------------- + +$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" + +$cmdExe = "C:\Windows\System32\cmd.exe" +$demoDirFwd = $DemoDir.Replace('\', '/') +$roSrc = "$DemoDir-ro-src" # matches e2e-policies/fs-readonly.yaml read_only path +$denyProbe = "$DemoDir-deny-probe" # ungranted, NOT the share: used to prove default-deny + +$script:registered = $false + +# Pristine TOML captured once inside the try (below); every scenario renders a +# fresh copy from it. Per-scenario gateway logs are assigned inside the loop so +# each scenario's stdout/stderr lands in its own file under $resultDir. +$tomlBase = $null +$gwLog = $null +$gwErrLog = $null + +# --- Helpers ------------------------------------------------------------------ + +# Render host-runtime settings from the pristine base. Sandbox workload +# settings are create-time driver config, not gateway-wide TOML. +function Render-Toml { + $t = $tomlBase + $t = [regex]::Replace($t, '(?m)^\s*#?\s*backend\s*=.*$', "backend = `"$Backend`"") + if (-not $Mock) { + $wxcLine = "wxc_exec_path = `"$(Esc $WxcExecPath)`"" + $t = [regex]::Replace($t, '(?m)^\s*#?\s*wxc_exec_path\s*=.*$', $wxcLine) + } + Set-Content $toml -Value $t -Encoding UTF8 +} + +function Start-Gw { + Remove-Item $gwLog, $gwErrLog -Force -ErrorAction SilentlyContinue + # Ephemeral in-memory DB: this is a test harness, so it must NOT write sandbox + # records to the persistent default store (%LOCALAPPDATA%\openshell\gateway\ + # openshell.db). Without this, sandbox names persist across gateway restarts + # and across runs, colliding on `create` ("already exists") and leaving orphan + # records behind. In-memory means every gateway starts clean and leaves nothing. + $p = Start-Process -FilePath $gateway ` + -ArgumentList @("--disable-tls", "--db-url", "sqlite::memory:", "--config", $toml, "--log-level", "info") ` + -WorkingDirectory $here -PassThru -NoNewWindow ` + -RedirectStandardOutput $gwLog -RedirectStandardError $gwErrLog + $deadline = (Get-Date).AddSeconds(30) + while ((Get-Date) -lt $deadline) { + if ($p.HasExited) { + Get-Content $gwLog, $gwErrLog -Encoding UTF8 -ErrorAction SilentlyContinue | ForEach-Object { Info $_ } + throw "gateway exited early (code $($p.ExitCode)). See $gwLog." + } + if (Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue) { + return $p + } + Start-Sleep -Milliseconds 400 + } + # Timed out but the process is still alive (never bound $Port). $gw is not yet + # assigned in the caller, so the finally block can't reap it - kill it here to + # avoid leaving an orphan gateway holding the port for the next run. + if (-not $p.HasExited) { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue } + throw "gateway did not start within 30 s." +} + +function Stop-Gw($p) { + if ($p -and -not $p.HasExited) { + Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue + } + Start-Sleep -Milliseconds 700 # let the listen socket release before the next start +} + +function Register-Cli { + if ($script:registered) { return } + $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)" } + $script:registered = $true +} + function Wait-File([string]$path, [int]$seconds) { $deadline = (Get-Date).AddSeconds($seconds) while ((Get-Date) -lt $deadline -and -not (Test-Path $path)) { @@ -68,6 +165,9 @@ function Wait-File([string]$path, [int]$seconds) { return (Test-Path $path) } +# Detect an agent *launch* failure (binary not found / not implemented) vs a +# legitimate policy denial. Used to avoid false-passing a deny scenario when the +# agent never actually ran. function Launch-Failed([string]$gwText) { if ($null -eq $gwText) { return $false } return ($gwText -match 'CreateProcessW failed error:2' ` @@ -78,70 +178,31 @@ function Launch-Failed([string]$gwText) { -or $gwText -match 'velocity') } -# ── Pre-flight ──────────────────────────────────────────────────────────────── +# --- Backend probe ------------------------------------------------------------ -# 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 ($Mock) { 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") { - # wxc-exec treats config paths literally and does not expand %TEMP%. - # Use a real user-owned directory and an absolute executable path. + # Use a REAL directory + absolute cmd.exe: the canonical wxc-exec passes + # cwd straight to CreateProcessW and does NOT expand %TEMP% (that yields + # 0x8007010B "directory name is invalid"). $probeDir = Join-Path $env:TEMP "mxc-e2e-probe" New-Item -ItemType Directory -Force $probeDir | Out-Null $config = @{ version = "0.6.0-alpha" containerId = "e2e-probe-pc" containment = "processcontainer" - process = @{ - commandLine = "C:\Windows\System32\cmd.exe /c exit 0" - cwd = $probeDir - timeout = 30000 # MXC process.timeout is milliseconds - } + process = @{ commandLine = "C:\Windows\System32\cmd.exe /c exit 0"; cwd = $probeDir; timeout = 30000 } # ms (MXC process.timeout is milliseconds) filesystem = @{ readwritePaths = @($probeDir) } processContainer = @{ leastPrivilege = $false } } - $json = $config | ConvertTo-Json -Depth 20 -Compress - $bytes = [System.Text.Encoding]::UTF8.GetBytes($json) - $b64 = [Convert]::ToBase64String($bytes) + $b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes(($config | ConvertTo-Json -Depth 20 -Compress))) $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" } - } + 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)" @@ -174,7 +235,7 @@ function Probe-Backend([string] $backendName, [string] $wxc) { if ($exitCode -ne 0) { return @{ Live = $false; Reason = "isolation_session probe failed: exit $exitCode" } } - # Provision succeeded — deprovision immediately. + # Provision succeeded - deprovision immediately. $sandboxId = $null try { $rawOut = ($outObj -join "`n") @@ -202,160 +263,114 @@ function Probe-Backend([string] $backendName, [string] $wxc) { return @{ Live = $false; Reason = "unknown backend: $backendName" } } -# ── Mode setup ──────────────────────────────────────────────────────────────── +# --- Run ---------------------------------------------------------------------- +# Everything that can throw runs inside this try so the finally always produces +# the results bundle (summary + transcript + zip), even on a pre-flight failure. -$mode = if ($Mock) { "MOCK" } else { "REAL" } -Step "Pre-flight (mode=$mode, backend=$Backend)" +$results = @() +$gw = $null +$harnessError = $null +$backendProbe = @{ Live = $false; Reason = "not probed" } +# Unique per-run suffix so a stale sandbox record from an earlier run can never +# collide with this run's `sandbox create` (the gateway persists names on disk). +$runId = Get-Date -Format 'yyyyMMddHHmmss' -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" -} +try { + # --- Pre-flight ----------------------------------------------------------- -# 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") + if (-not $Mock) { + if ($env:OPENSHELL_MXC_MOCK_WXC -eq "1") { + throw "OPENSHELL_MXC_MOCK_WXC=1 is set but -Mock was not passed. " + + "Unset OPENSHELL_MXC_MOCK_WXC or pass -Mock." + } } -} -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" -$roSrc = "$DemoDir-ro-src" -$denyProbe = "$DemoDir-deny-probe" -New-Item -ItemType Directory -Force $DemoDir, $roSrc, $denyProbe | Out-Null -Ok "DemoDir ready" + 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" + } -$env:OPENSHELL_DRIVERS = "mxc" -$env:OPENSHELL_MXC_SHARE_DIR = $DemoDir -$cmdExe = "C:\Windows\System32\cmd.exe" + # Capture the pristine TOML once; every scenario renders a fresh copy from this. + $tomlBase = Get-Content $toml -Raw -# ── Start gateway ───────────────────────────────────────────────────────────── + # --- Mode setup ----------------------------------------------------------- -Step "Start gateway" -$gwLog = Join-Path $here "gateway.e2e.log" -$gwErrLog = "$gwLog.err" -Remove-Item $gwLog, $gwErrLog -Force -ErrorAction SilentlyContinue + Step "Pre-flight (mode=$(if ($Mock) {'MOCK'} else {'REAL'}), backend=$Backend)" -$gw = Start-Process -FilePath $gateway ` - -ArgumentList @("--disable-tls", "--db-url", "sqlite::memory:", "--config", $toml, "--log-level", "info") ` - -WorkingDirectory $here -PassThru -NoNewWindow ` - -RedirectStandardOutput $gwLog -RedirectStandardError $gwErrLog + 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" + } -Info "gateway pid $($gw.Id); logs: $gwLog" + $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." + } -$results = @() -$runId = Get-Date -Format 'yyyyMMddHHmmss' + 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" -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" + Step "Prepare DemoDir + read-only source + deny-probe dir" + New-Item -ItemType Directory -Force $DemoDir | Out-Null + New-Item -ItemType Directory -Force $roSrc | Out-Null + New-Item -ItemType Directory -Force $denyProbe | Out-Null + Set-Content -Path (Join-Path $roSrc "seed.txt") -Value "read-only seed" -Encoding UTF8 + Ok "DemoDir=$DemoDir roSrc=$roSrc denyProbe=$denyProbe" - # 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" + $env:OPENSHELL_DRIVERS = "mxc" + $env:OPENSHELL_MXC_SHARE_DIR = $DemoDir - # ── Scenario definitions ────────────────────────────────────────────────── - # - # Each scenario is positive, deny, or create-fail. Deny scenarios require - # both a successful write to a granted control path and an absent denied - # target, so an agent launch failure cannot be mistaken for enforcement. + # --- Scenario definitions ------------------------------------------------- + # Kind: positive | deny | create-fail + # For deny: ControlTarget (granted, must be PRESENT) + DenyTarget (must be ABSENT). $allScenarios = @( @{ - Name = "fs-rw" - PolicyFile = Join-Path $policyDir "fs-rw.yaml" - Backends = "both" - Kind = "positive" - PosTarget = Join-Path $DemoDir "fs-rw-result.txt" + Name = "fs-rw"; PolicyFile = Join-Path $policyDir "fs-rw.yaml" + Backends = "both"; Kind = "positive" + PosTarget = (Join-Path $DemoDir "fs-rw-result.txt") Description = "rw grant on DemoDir; in-policy write should succeed" }, @{ - Name = "fs-readonly" - PolicyFile = Join-Path $policyDir "fs-readonly.yaml" - Backends = "both" - Kind = "deny" - ControlTarget = Join-Path $DemoDir "fs-readonly-control.txt" - DenyTarget = Join-Path $roSrc "fs-readonly-deny.txt" + Name = "fs-readonly"; PolicyFile = Join-Path $policyDir "fs-readonly.yaml" + Backends = "both"; Kind = "deny" + ControlTarget = (Join-Path $DemoDir "fs-readonly-control.txt") + DenyTarget = (Join-Path $roSrc "fs-readonly-denied.txt") Description = "write to read-only dir denied; control write to rw dir succeeds" }, @{ - Name = "fs-default-deny" - PolicyFile = Join-Path $policyDir "fs-empty.yaml" - Backends = "process_container" - Kind = "deny" - ControlTarget = Join-Path $DemoDir "fs-default-deny-control.txt" - DenyTarget = Join-Path $denyProbe "fs-default-deny-denied.txt" + Name = "fs-default-deny"; PolicyFile = Join-Path $policyDir "fs-empty.yaml" + Backends = "process_container"; Kind = "deny" + # share_dir (DemoDir) is mapped rw by design, so it is NOT a valid deny + # probe. Prove default-deny by writing to an ungranted, out-of-share path; + # a control write to the share proves the agent actually ran. + ControlTarget = (Join-Path $DemoDir "fs-default-deny-control.txt") + DenyTarget = (Join-Path $denyProbe "fs-default-deny-denied.txt") Description = "empty policy; ungranted write denied; share control write succeeds" }, @{ - Name = "network-reject" - PolicyFile = Join-Path $policyDir "network-reject.yaml" - Backends = "both" - Kind = "create-fail" + Name = "network-reject"; PolicyFile = Join-Path $policyDir "network-reject.yaml" + Backends = "both"; Kind = "create-fail" 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) { @@ -364,164 +379,242 @@ try { $allScenarios = $filtered } - # ── Run scenarios ───────────────────────────────────────────────────────── - - foreach ($sc in $allScenarios) { - Step "Scenario: $($sc.Name)" - Info $sc.Description - - # Backend gate: create-fail validates translation and does not need a - # live backend. Enforcement scenarios do. - $skipReason = $null - if ($sc.Kind -ne "create-fail") { - $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)" + # --- Scenario loop -------------------------------------------------------- + + try { + foreach ($sc in $allScenarios) { + Step "Scenario: $($sc.Name)" + Info $sc.Description + + # Backend gate (deny/positive scenarios need a live backend; create-fail does not). + $skipReason = $null + if ($sc.Kind -ne "create-fail") { + $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 } - } - - 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 - } - # Build the per-sandbox command. Commands and working directories are - # create-time inputs, so the gateway does not restart between scenarios. - if ($sc.Kind -eq "positive") { - Remove-Item $sc.PosTarget -Force -ErrorAction SilentlyContinue - $command = @($cmdExe, "/c", "echo ok 1> $($sc.PosTarget.Replace('\', '/'))") - } elseif ($sc.Kind -eq "deny") { - Remove-Item $sc.ControlTarget, $sc.DenyTarget -Force -ErrorAction SilentlyContinue - $control = $sc.ControlTarget.Replace('\', '/') - $denied = $sc.DenyTarget.Replace('\', '/') - $command = @($cmdExe, "/c", "echo ok 1> $control & echo denied 1> $denied") - } else { - $command = @($cmdExe, "/c", "exit 0") - } + 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 + } - $demoDirFwd = $DemoDir.Replace('\', '/') - $driverConfig = @{ - mxc = @{ - command = $command - cwd = $demoDirFwd + # Per-scenario gateway logs land in the bundle under their own names. + $gwLog = Join-Path $resultDir "gateway.$($sc.Name).log" + $gwErrLog = Join-Path $resultDir "gateway.$($sc.Name).err.log" + + # Build the per-sandbox workload command and clean prior artifacts. + if ($sc.Kind -eq "positive") { + Remove-Item $sc.PosTarget -Force -ErrorAction SilentlyContinue + $command = @($cmdExe, "/c", "echo ok 1> $($sc.PosTarget.Replace('\', '/'))") + } elseif ($sc.Kind -eq "deny") { + Remove-Item $sc.ControlTarget, $sc.DenyTarget -Force -ErrorAction SilentlyContinue + $control = $sc.ControlTarget.Replace('\', '/') + $denied = $sc.DenyTarget.Replace('\', '/') + $command = @($cmdExe, "/c", "echo ok 1> $control & echo denied 1> $denied") + } else { + $command = @($cmdExe, "/c", "exit 0") } - } | 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 - } - $sandboxName = "$($sc.Name)-$runId" - try { & $cli sandbox delete $sandboxName 2>&1 | Out-Null } catch {} - # Run sandbox create. Its exit status is only authoritative for the - # create-fail scenario; artifacts score workload scenarios. - $createOut = $null - $createExitCode = 0 - try { - $createOut = & $cli sandbox create --name $sandboxName --policy $sc.PolicyFile --driver-config-json $driverConfigArg --no-tty 2>&1 - $createExitCode = $LASTEXITCODE - } catch { - $createOut = $_.Exception.Message - $createExitCode = 1 - } - $createOutStr = ($createOut -join "`n") - Info "create exit: $createExitCode (not used for workload scoring)" - - # Delete sandbox (best-effort; no-op if create failed). - try { & $cli sandbox delete $sandboxName 2>&1 | Out-Null } catch {} - $gwText = (Get-Content $gwLog, $gwErrLog -Raw -ErrorAction SilentlyContinue) -join [Environment]::NewLine - - # Evaluate. - if ($sc.Kind -eq "create-fail") { - # A non-zero exit alone is NOT sufficient: gateway-registration, - # transport, or malformed-fixture errors also exit non-zero and would - # false-pass this scenario. Require a genuine policy-rejection signal - # and confirm it is not an infrastructure failure. - $rejected = ($createOutStr -match '(?i)network' ` - -or $createOutStr -match '(?i)invalid[_ -]?argument' ` - -or $createOutStr -match '(?i)policy' ` - -or $gwText -match '(?i)network_policies') - $infraFail = ($createOutStr -match '(?i)connection refused' ` - -or $createOutStr -match '(?i)not registered' ` - -or $createOutStr -match '(?i)transport error' ` - -or $createOutStr -match '(?i)failed to connect') - if ($createExitCode -ne 0 -and $rejected -and -not $infraFail) { - Ok "$($sc.Name): create correctly rejected by policy (exit $createExitCode)" - $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "policy rejection" } - } elseif ($createExitCode -ne 0) { - Bad "$($sc.Name): create failed but not with a policy-rejection signal (possible harness/infra error)" - Info "output: $createOutStr" - $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "non-rejection failure" } + $driverConfig = @{ + mxc = @{ + command = $command + 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 { - Bad "$($sc.Name): create succeeded but should have failed" - Info "output: $createOutStr" - $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "create succeeded unexpectedly" } + $driverConfig } - } elseif ($sc.Kind -eq "positive") { - if (Wait-File $sc.PosTarget 30) { - Ok "$($sc.Name): in-policy write produced artifact" - $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "artifact present" } + + Render-Toml + # Preserve the exact rendered config + policy fixture used for this scenario. + Copy-Item $toml (Join-Path $resultDir "mxc-gateway.$($sc.Name).toml") -Force -ErrorAction SilentlyContinue + Copy-Item $sc.PolicyFile (Join-Path $resultDir "policy.$($sc.Name).yaml") -Force -ErrorAction SilentlyContinue + + $gw = Start-Gw + Info "gateway pid $($gw.Id)" + Register-Cli + + # Unique per-run sandbox name; pre-delete for belt-and-suspenders. + $sandboxName = "$($sc.Name)-$runId" + try { & $cli sandbox delete $sandboxName 2>&1 | Out-Null } catch {} + + # Run sandbox create. Its exit status is only authoritative for the + # create-fail scenario; artifacts score workload scenarios. + $createOut = $null; $createExitCode = 0 + try { + $createOut = & $cli sandbox create --name $sandboxName --policy $sc.PolicyFile --driver-config-json $driverConfigArg --no-tty 2>&1 + $createExitCode = $LASTEXITCODE + } catch { + $createOut = $_.Exception.Message; $createExitCode = 1 + } + $createOutStr = ($createOut -join "`n") + Info "create exit: $createExitCode (not used for scoring on non-create-fail scenarios)" + try { & $cli sandbox delete $sandboxName 2>&1 | Out-Null } catch {} + + $gwText = (Get-Content $gwLog, $gwErrLog -Raw -ErrorAction SilentlyContinue) -join "`n" + + # Evaluate. + if ($sc.Kind -eq "create-fail") { + # A non-zero exit alone is NOT sufficient: gateway-registration, + # transport, or malformed-fixture errors also exit non-zero and would + # false-pass this scenario. Require a genuine policy-rejection signal + # (the driver rejects the network rule with invalid_argument naming + # network_policies) AND confirm it is not an infrastructure failure. + $rejected = ($createOutStr -match '(?i)network' ` + -or $createOutStr -match '(?i)invalid[_ -]?argument' ` + -or $createOutStr -match '(?i)policy' ` + -or $gwText -match '(?i)network_policies') + $infraFail = ($createOutStr -match '(?i)connection refused' ` + -or $createOutStr -match '(?i)not registered' ` + -or $createOutStr -match '(?i)transport error' ` + -or $createOutStr -match '(?i)failed to connect') + if ($createExitCode -ne 0 -and $rejected -and -not $infraFail) { + Ok "$($sc.Name): create correctly rejected by policy (exit $createExitCode)" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "policy rejection" } + } elseif ($createExitCode -ne 0) { + Bad "$($sc.Name): create failed but not with a policy-rejection signal (possible harness/infra error)" + Info "output: $createOutStr" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "non-rejection failure" } + } else { + Bad "$($sc.Name): create succeeded but should have failed" + Info "output: $createOutStr" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "create succeeded unexpectedly" } + } + } elseif ($sc.Kind -eq "positive") { + $present = Wait-File $sc.PosTarget 30 + if ($present) { + Ok "$($sc.Name): in-policy write produced artifact" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "artifact present" } + } else { + Bad "$($sc.Name): artifact absent ($($sc.PosTarget))" + Info "createOut: $createOutStr" + if (Launch-Failed $gwText) { Info "gateway log shows an agent-launch failure (not a policy result)" } + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "artifact absent" } + } } else { - Bad "$($sc.Name): artifact absent ($($sc.PosTarget))" - Info "createOut: $createOutStr" - if (Launch-Failed $gwText) { Info "gateway log shows an agent-launch failure" } - $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "artifact absent" } + # deny + if ($sc.ControlTarget) { + $controlPresent = Wait-File $sc.ControlTarget 30 + # Snapshot the deny target only AFTER the control artifact lands, so a + # late denied write (enforcement regression racing the control write) + # cannot be recorded as PASS. + $denyPresent = Test-Path $sc.DenyTarget + if ($controlPresent -and -not $denyPresent) { + Ok "$($sc.Name): control write succeeded; denied write correctly blocked" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "control present, deny absent" } + } elseif (-not $controlPresent) { + Bad "$($sc.Name): control write absent - agent did not run correctly (inconclusive denial)" + Info "createOut: $createOutStr" + if (Launch-Failed $gwText) { Info "gateway log shows an agent-launch failure" } + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "control absent (agent did not run)" } + } else { + Bad "$($sc.Name): denied write was NOT blocked (artifact present)" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "deny target present (not enforced)" } + } + } else { + # No control target (empty policy): give the denied write a moment, then assert absent. + Start-Sleep -Seconds 3 + $denyPresent = Test-Path $sc.DenyTarget + if ($denyPresent) { + Bad "$($sc.Name): denied write was NOT blocked (artifact present)" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "deny target present (not enforced)" } + } elseif (Launch-Failed $gwText) { + Bad "$($sc.Name): artifact absent but agent failed to launch - inconclusive" + Info "createOut: $createOutStr" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "agent launch failed (inconclusive)" } + } else { + Ok "$($sc.Name): write correctly denied (artifact absent, agent launched)" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "deny absent (default-deny enforced)" } + } + } } - } else { - $controlPresent = Wait-File $sc.ControlTarget 30 - # Snapshot the deny target only after the control artifact lands so a - # late denied write cannot be recorded as a pass. - $denyPresent = Test-Path $sc.DenyTarget - if ($controlPresent -and -not $denyPresent) { - Ok "$($sc.Name): control write succeeded; denied write correctly blocked" - $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "control present, deny absent" } - } elseif (-not $controlPresent) { - Bad "$($sc.Name): control write absent; denial result is inconclusive" - Info "createOut: $createOutStr" - if (Launch-Failed $gwText) { Info "gateway log shows an agent-launch failure" } - $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "control absent (agent did not run)" } + + if ($KeepRunning) { + Info "leaving gateway pid $($gw.Id) running (-KeepRunning); stopping after the first scenario so the next one doesn't collide on port $Port" + break } else { - Bad "$($sc.Name): denied write was not blocked" - $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "deny target present" } + Stop-Gw $gw + $gw = $null } } - } - -} 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)" + } finally { + if ($gw -and -not $KeepRunning) { Stop-Gw $gw } + if ($KeepRunning -and $gw) { Info "gateway pid $($gw.Id) left running (-KeepRunning)" } } } +catch { + $harnessError = $_.Exception.Message + Bad "harness error: $harnessError" +} +finally { + # --- Summary + results bundle --------------------------------------------- + Step "Summary" + $results | Format-Table -AutoSize + + # Wrap in @() so a single match still yields an array with a .Count (PS 5.1). + $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" + + $verdict = if ($harnessError -or $failCount -gt 0) { "FAIL" } else { "PASS" } + $tableText = ($results | Format-Table -AutoSize | Out-String) + $summary = @" +OpenShell MXC e2e scenario run +============================== +timestamp : $stamp +machine : $env:COMPUTERNAME +verdict : $verdict +mode : $(if ($Mock) { 'MOCK' } else { 'REAL' }) +backend : $Backend +backend_live : $($backendProbe.Live) ($($backendProbe.Reason)) +wxc_exec : $WxcExecPath +gateway_port : $Port +totals : PASS=$passCount FAIL=$failCount SKIP=$skipCount +$(if ($harnessError) { "harness_error: $harnessError" }) + +Per-scenario results: +$tableText +Files in this bundle ($resultDir): + summary.txt this summary + transcript.txt full console transcript + gateway..log / .err.log per-scenario gateway stdout/stderr + mxc-gateway..toml the exact gateway config rendered per scenario + policy..yaml the exact sandbox policy fixture used per scenario + +What PASS means: every non-skipped scenario met its expected verdict - positive +writes produced their artifact, deny writes were blocked (with a control write +proving the agent ran), and the network-reject scenario was refused by policy. +"@ + Set-Content -Path (Join-Path $resultDir "summary.txt") -Value $summary -Encoding UTF8 + Write-Host $summary -ForegroundColor ($(if ($verdict -eq "PASS") { "Green" } else { "Red" })) + + try { Stop-Transcript | Out-Null } catch {} + + # Zip the bundle for easy return (defensive; never throw out of finally). + try { + $zip = Join-Path $here "results-e2e-$stamp.zip" + if (Test-Path $zip) { Remove-Item $zip -Force } + Compress-Archive -Path (Join-Path $resultDir "*") -DestinationPath $zip -Force + Write-Host "`nResults bundle: $zip" -ForegroundColor Yellow + } catch { Write-Host "zip failed: $($_.Exception.Message)" -ForegroundColor Red } +} -# ── 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) { +if ($harnessError -or $failCount -gt 0) { Write-Host "`nSOME SCENARIOS FAILED" -ForegroundColor Red exit 1 } else { From 296761e899a3171fa4be01b7589ee35f9a8a6b4a Mon Sep 17 00:00:00 2001 From: Prashant S Khodade Date: Thu, 16 Jul 2026 21:34:10 +0530 Subject: [PATCH 28/31] fix(mxc-e2e): address CodeRabbit review on run-mxc-e2e.ps1 - Require -Scenario when -KeepRunning: the loop breaks after the first scenario, so a full-suite run would execute only one scenario yet still report the suite as PASS. Fail fast so a partial run can't be mislabeled complete. - Start-Transcript now runs inside the guarded try block with a $transcriptStarted flag; Stop-Transcript is only called when it actually started, so a Start-Transcript failure still yields the results bundle. - Wrap the -Scenario filter in @() so a single exact match stays an array (reliable .Count and a proper array for the scenario loop on PS 5.1). Signed-off-by: Prashant S Khodade Signed-off-by: Akber Raza --- .../examples/run-mxc-e2e.ps1 | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 index b16c1662a9..e3c6f6d72a 100644 --- a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 +++ b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 @@ -60,11 +60,12 @@ $here = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path } # --- Results bundle ----------------------------------------------------------- # Collect every log + the exact rendered config per scenario into a timestamped # results\ folder, then zip it (mirrors the sibling run-*.ps1 scripts). Created up -# front so the transcript captures the whole run, including pre-flight failures. +# front so the transcript (started inside the guarded region below) captures the +# whole run, including pre-flight failures. $stamp = Get-Date -Format "yyyyMMdd-HHmmss" $resultDir = Join-Path $here "results-e2e-$stamp" New-Item -ItemType Directory -Force $resultDir | Out-Null -Start-Transcript -Path (Join-Path $resultDir "transcript.txt") -Force | Out-Null +$transcriptStarted = $false function Step([string]$m) { Write-Host "`n=== $m ===" -ForegroundColor Cyan } function Info([string]$m) { Write-Host " $m" } @@ -276,6 +277,12 @@ $backendProbe = @{ Live = $false; Reason = "not probed" } $runId = Get-Date -Format 'yyyyMMddHHmmss' try { + # Start the transcript inside the guarded region so a Start-Transcript failure + # is caught and the results bundle is still produced. Pre-flight runs + # immediately below, so the transcript still captures the whole run. + Start-Transcript -Path (Join-Path $resultDir "transcript.txt") -Force | Out-Null + $transcriptStarted = $true + # --- Pre-flight ----------------------------------------------------------- if (-not $Mock) { @@ -285,6 +292,14 @@ try { } } + # -KeepRunning leaves the gateway up and breaks after the FIRST scenario (so the + # next one cannot collide on the port). A full-suite run would therefore execute + # only one scenario yet still report the suite as PASS. Require a single, + # explicitly-selected scenario so a partial run can never be mislabeled complete. + if ($KeepRunning -and -not $Scenario) { + throw "-KeepRunning requires -Scenario: it stops after the first scenario, so a full-suite run would report PASS on partial results. Re-run with e.g. -Scenario fs-rw-positive-negative -KeepRunning." + } + foreach ($f in @($gateway, $cli, $toml)) { if (-not (Test-Path $f)) { throw "Missing artifact: $f`nBuild first or run from a demo-package folder." @@ -372,7 +387,10 @@ try { ) if ($Scenario) { - $filtered = $allScenarios | Where-Object { $_.Name -eq $Scenario } + # Wrap in @() so an exact single match stays an array: without it a lone + # match is a bare hashtable, its .Count is unreliable on PS 5.1, and + # $allScenarios would no longer be an array for the loop below. + $filtered = @($allScenarios | Where-Object { $_.Name -eq $Scenario }) if ($filtered.Count -eq 0) { throw "Scenario '$Scenario' not found. Available: $(($allScenarios | ForEach-Object { $_.Name }) -join ', ')" } @@ -603,7 +621,7 @@ proving the agent ran), and the network-reject scenario was refused by policy. Set-Content -Path (Join-Path $resultDir "summary.txt") -Value $summary -Encoding UTF8 Write-Host $summary -ForegroundColor ($(if ($verdict -eq "PASS") { "Green" } else { "Red" })) - try { Stop-Transcript | Out-Null } catch {} + if ($transcriptStarted) { try { Stop-Transcript | Out-Null } catch {} } # Zip the bundle for easy return (defensive; never throw out of finally). try { From 555e872ce79dbd26a83b600da49cb7fd3b7fc493 Mon Sep 17 00:00:00 2001 From: Jamie King Date: Fri, 17 Jul 2026 10:46:33 -0600 Subject: [PATCH 29/31] fix(examples): pass gateway config via OPENSHELL_GATEWAY_CONFIG for spaced paths Start-Process -ArgumentList does not quote array elements, so launching the gateway with a bare --config token split on any space in the install path (e.g. C:\Users\First Last\...), and clap rejected the fragment with 'unrecognized subcommand'. Every MXC example launcher that started the gateway hit this when the kit was unzipped under a path containing a space. Pass the config path through the OPENSHELL_GATEWAY_CONFIG env var (which the gateway already reads via clap) and drop the --config token. Env vars carry spaces safely. Affected: run-ocsf-audit, run-mxc-e2e, run-demo, run-inference-test, run-ollama-test. run-mtls-test was not affected (its launch passes no config path). Root-caused and fix-verified on 7F203-MXC-003 from a spaced path. Signed-off-by: Akber Raza --- crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 | 6 +++++- crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 index e3c6f6d72a..3d5d4642c7 100644 --- a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 +++ b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 @@ -119,8 +119,12 @@ function Start-Gw { # openshell.db). Without this, sandbox names persist across gateway restarts # and across runs, colliding on `create` ("already exists") and leaving orphan # records behind. In-memory means every gateway starts clean and leaves nothing. + # Config path goes through the env var (clap: OPENSHELL_GATEWAY_CONFIG), NOT a + # --config token: Start-Process -ArgumentList does not quote array elements, so a + # config path containing a space gets split and the gateway's arg parser rejects it. + $env:OPENSHELL_GATEWAY_CONFIG = $toml $p = Start-Process -FilePath $gateway ` - -ArgumentList @("--disable-tls", "--db-url", "sqlite::memory:", "--config", $toml, "--log-level", "info") ` + -ArgumentList @("--disable-tls", "--db-url", "sqlite::memory:", "--log-level", "info") ` -WorkingDirectory $here -PassThru -NoNewWindow ` -RedirectStandardOutput $gwLog -RedirectStandardError $gwErrLog $deadline = (Get-Date).AddSeconds(30) diff --git a/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 index e36f995f4e..fa2eb23ac9 100644 --- a/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 +++ b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 @@ -160,6 +160,10 @@ try { $env:OPENSHELL_WXC_EXEC_PATH = $WxcExecPath $env:OPENSHELL_OCSF_JSON = "1" $env:OPENSHELL_OCSF_LOG_DIR = $resultDir + # Config path goes through the env var (clap: OPENSHELL_GATEWAY_CONFIG), NOT a + # --config token: Start-Process -ArgumentList does not quote array elements, so a + # config path containing a space gets split and the gateway's arg parser rejects it. + $env:OPENSHELL_GATEWAY_CONFIG = $toml Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue # 7. Start the gateway (background, TLS disabled on the loopback control plane). @@ -167,7 +171,7 @@ try { $gwLog = Join-Path $resultDir "gateway.log" $gwErrLog = Join-Path $resultDir "gateway.err.log" $gw = Start-Process -FilePath $gateway ` - -ArgumentList @("--disable-tls", "--config", $toml, "--log-level", "info") ` + -ArgumentList @("--disable-tls", "--log-level", "info") ` -WorkingDirectory $here -PassThru -NoNewWindow ` -RedirectStandardOutput $gwLog -RedirectStandardError $gwErrLog Info "gateway pid $($gw.Id); logs -> $(Split-Path $gwLog -Leaf) (+ .err)" From 81adeeca6ba4611c9c09d89738c88630894af44f Mon Sep 17 00:00:00 2001 From: Prashant S Khodade Date: Mon, 3 Aug 2026 15:31:51 +0200 Subject: [PATCH 30/31] fix(compute): [regression 662dee68] MXC sandbox stuck in Provisioning forever ComposedPhase::new (introduced in 662dee68) determines SandboxPhase::Ready by requiring a live supervisor session (session_connected == true). For backends that have no in-sandbox supervisor (e.g. MXC), session_connected is always false, so the public phase was permanently stuck at Provisioning even after the driver reported Ready=True. The CLI watch loop in sandbox_create blocks until it observes a Provisioning -> Ready transition, so it would spin until the 300-second idle timeout fired -- appearing as a hang to the user. Fix: thread supports_interactive_session (already stored on ComputeRuntime as has_supervisor) through apply_driver_snapshot into ComposedPhase::new. When has_supervisor is false the backend phase passes through directly as the authoritative readiness signal, matching the pre-662dee68 behaviour for MXC. Also guard backend_ready_without_session with has_supervisor so supervisorless backends do not emit the misleading SupervisorNotConnected status condition. Regression introduced by: 662dee68 refactor(compute): make sandbox readiness gateway-owned across all drivers (#2153) Signed-off-by: Prashant S Khodade Signed-off-by: Akber Raza --- crates/openshell-server/src/compute/mod.rs | 33 +++++++++++++++++----- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index b85641c986..e49a5df2ed 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -1406,10 +1406,11 @@ impl ComputeRuntime { let sandbox_id = transition.object_id().to_string(); let expected_resource_version = sandbox_resource_version(transition); let session_connected = self.supervisor_sessions.has_session(&sandbox_id); + let has_supervisor = self.driver_kind() != Some(ComputeDriverKind::Mxc); match self .store .update_message_cas::(&sandbox_id, expected_resource_version, |sandbox| { - apply_driver_snapshot(sandbox, snapshot, session_connected); + apply_driver_snapshot(sandbox, snapshot, session_connected, has_supervisor); }) .await { @@ -1853,11 +1854,14 @@ impl ComputeRuntime { match observed { Ok(Some(snapshot)) if snapshot.id == sandbox_id && snapshot.status.is_some() => { let session_connected = self.supervisor_sessions.has_session(sandbox_id); + let has_supervisor = self.driver_kind() != Some(ComputeDriverKind::Mxc); self.write_delete_recovery_with_retry( sandbox_id, deleting_resource_version, "reconcile observed backend snapshot", - |sandbox| apply_driver_snapshot(sandbox, &snapshot, session_connected), + |sandbox| { + apply_driver_snapshot(sandbox, &snapshot, session_connected, has_supervisor) + }, ) .await; } @@ -2823,12 +2827,13 @@ impl ComputeRuntime { existing_phase: SandboxPhase, ) -> Result<(), String> { let session_connected = self.supervisor_sessions.has_session(&incoming.id); + let has_supervisor = self.driver_kind() != Some(ComputeDriverKind::Mxc); let sandbox = self .store .update_message_cas::( &incoming.id, expected_resource_version, - |sandbox| apply_driver_snapshot(sandbox, &incoming, session_connected), + |sandbox| apply_driver_snapshot(sandbox, &incoming, session_connected, has_supervisor), ) .await .map_err(|e| match e { @@ -3762,7 +3767,12 @@ fn public_status_from_driver( } } -fn apply_driver_snapshot(sandbox: &mut Sandbox, incoming: &DriverSandbox, session_connected: bool) { +fn apply_driver_snapshot( + sandbox: &mut Sandbox, + incoming: &DriverSandbox, + session_connected: bool, + has_supervisor: bool, +) { let old_phase = SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown); let sandbox_name = &incoming.name; @@ -3794,7 +3804,7 @@ fn apply_driver_snapshot(sandbox: &mut Sandbox, incoming: &DriverSandbox, sessio (phase, status) }, |incoming_status| { - let composed = ComposedPhase::new(incoming_status, session_connected); + let composed = ComposedPhase::new(incoming_status, session_connected, has_supervisor); let mut status = Some(public_status_from_driver( incoming_status, composed.phase, @@ -3926,21 +3936,30 @@ struct ComposedPhase { } impl ComposedPhase { - fn new(incoming_status: &DriverSandboxStatus, session_connected: bool) -> Self { + fn new( + incoming_status: &DriverSandboxStatus, + session_connected: bool, + has_supervisor: bool, + ) -> Self { let backend_phase = derive_phase(Some(incoming_status)); // A live supervisor session is a stronger readiness signal than the backend phase. // set_supervisor_session_state may have already promoted the store record to Ready // before this driver snapshot arrived. Keep Ready rather than letting a lagging // backend phase overwrite it. + // + // Backends without a supervisor (e.g. MXC) have no session to wait for; treat the + // backend phase as authoritative so they can reach Ready without a supervisor. let phase = match backend_phase { SandboxPhase::Error | SandboxPhase::Deleting | SandboxPhase::Stopped => backend_phase, _ if session_connected => SandboxPhase::Ready, + SandboxPhase::Ready if !has_supervisor => SandboxPhase::Ready, _ => SandboxPhase::Provisioning, }; Self { phase, session_connected, - backend_ready_without_session: backend_phase == SandboxPhase::Ready + backend_ready_without_session: has_supervisor + && backend_phase == SandboxPhase::Ready && !session_connected, } } From 99c9f74596ad00f2165b347b4ff1267cd6a7f48c Mon Sep 17 00:00:00 2001 From: Akber Raza Date: Wed, 2 Sep 2026 20:52:22 -0500 Subject: [PATCH 31/31] fix(run-mxc-e2e): improve scoring logic and enhance command execution handling --- .../examples/run-mxc-e2e.ps1 | 151 ++++++++++++++---- 1 file changed, 117 insertions(+), 34 deletions(-) diff --git a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 index 3d5d4642c7..562b34737a 100644 --- a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 +++ b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 @@ -18,9 +18,9 @@ # # Scoring (why we do NOT gate on `sandbox create` exit code): # The ground truth is the on-disk artifact, so positive scenarios pass on -# artifact PRESENT and deny -# scenarios pass on the denied write being ABSENT while a CONTROL write (to a -# granted path) is PRESENT -- which proves the agent actually ran. +# artifact PRESENT and deny scenarios pass on the denied write being ABSENT. +# A CONTROL write proves the agent ran when the policy grants a writable path; +# an empty policy instead requires explicit driver-launch evidence. # # PowerShell 5.1-compatible (no && / || / ternary operators). ASCII only. # @@ -32,14 +32,14 @@ # Scenarios & expected verdicts: # fs-rw - in-policy write to DemoDir succeeds. # fs-readonly - write to read-only dir is denied; control write succeeds. -# fs-default-deny - ungranted write is denied; control write succeeds. +# fs-default-deny - ungranted write is denied after the agent launches. # processcontainer only. # network-reject - network_policies rule makes sandbox create fail. [CmdletBinding()] param( [string] $DemoDir = "C:\work\openshell-mxc-e2e", - [string] $WxcExecPath = "C:\mxc\wxc-exec.exe", + [string] $WxcExecPath = "C:\mxc-kit\bin\wxc-exec.exe", [ValidateSet("isolation_session", "process_container")] [string] $Backend = "process_container", [string] $Scenario, @@ -77,6 +77,56 @@ function Warn([string]$m) { Write-Host "[WARN] $m" -ForegroundColor Yellow } # Double backslashes so a Windows path is a valid TOML/JSON basic-string element. function Esc([string]$p) { return $p.Replace('\', '\\') } +# Build one CreateProcess-compatible command-line argument. Windows PowerShell +# 5.1 can split JSON values at embedded spaces when invoking native commands +# through the call operator, even when PowerShell holds the JSON as one string. +function Quote-NativeArgument([string]$value) { + if ($value.Length -gt 0 -and $value -notmatch '[\s"]') { return $value } + + $quoted = New-Object System.Text.StringBuilder + [void]$quoted.Append('"') + $backslashes = 0 + foreach ($ch in $value.ToCharArray()) { + if ($ch -eq '\') { + $backslashes++ + continue + } + if ($ch -eq '"') { + [void]$quoted.Append(('\' * (2 * $backslashes + 1))) + [void]$quoted.Append('"') + } else { + if ($backslashes -gt 0) { [void]$quoted.Append(('\' * $backslashes)) } + [void]$quoted.Append($ch) + } + $backslashes = 0 + } + if ($backslashes -gt 0) { [void]$quoted.Append(('\' * (2 * $backslashes))) } + [void]$quoted.Append('"') + return $quoted.ToString() +} + +function Invoke-NativeCaptured([string]$filePath, [string[]]$argumentList) { + $startInfo = New-Object System.Diagnostics.ProcessStartInfo + $startInfo.FileName = $filePath + $startInfo.Arguments = (($argumentList | ForEach-Object { Quote-NativeArgument $_ }) -join ' ') + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $startInfo + if (-not $process.Start()) { throw "failed to start $filePath" } + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + + return @{ + ExitCode = $process.ExitCode + Output = @($stdout.Result, $stderr.Result) | Where-Object { $_ } + } +} + # --- Path variables ----------------------------------------------------------- $gateway = Join-Path $here "openshell-gateway.exe" @@ -155,10 +205,23 @@ function Stop-Gw($p) { function Register-Cli { if ($script:registered) { return } $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)" } + + $addResult = Invoke-NativeCaptured $cli @( + "gateway", "add", "http://127.0.0.1:$Port", "--local", "--name", $GatewayName + ) + $addText = ($addResult.Output -join "`n") + if ($addText) { $addResult.Output | ForEach-Object { Info $_ } } + if ($addResult.ExitCode -ne 0 -and $addText -notmatch '(?i)already exists') { + throw "gateway add failed (exit $($addResult.ExitCode)): $addText" + } + + $selectResult = Invoke-NativeCaptured $cli @("gateway", "select", $GatewayName) + $selectText = ($selectResult.Output -join "`n") + if ($selectText) { $selectResult.Output | ForEach-Object { Info $_ } } + if ($selectResult.ExitCode -ne 0) { + throw "gateway select failed (exit $($selectResult.ExitCode)): $selectText" + } + $script:registered = $true } @@ -183,6 +246,11 @@ function Launch-Failed([string]$gwText) { -or $gwText -match 'velocity') } +function Launch-Succeeded([string]$gwText) { + if ($null -eq $gwText) { return $false } + return ($gwText -match 'MXC agent launched') +} + # --- Backend probe ------------------------------------------------------------ function Probe-Backend([string] $backendName, [string] $wxc) { @@ -278,7 +346,8 @@ $harnessError = $null $backendProbe = @{ Live = $false; Reason = "not probed" } # Unique per-run suffix so a stale sandbox record from an earlier run can never # collide with this run's `sandbox create` (the gateway persists names on disk). -$runId = Get-Date -Format 'yyyyMMddHHmmss' +# Sandbox names are limited to 19 characters, so keep the timestamp compact. +$runId = Get-Date -Format 'MMddHHmmss' try { # Start the transcript inside the guarded region so a Start-Transcript failure @@ -362,12 +431,14 @@ try { $allScenarios = @( @{ Name = "fs-rw"; PolicyFile = Join-Path $policyDir "fs-rw.yaml" + SandboxId = "rw" Backends = "both"; Kind = "positive" PosTarget = (Join-Path $DemoDir "fs-rw-result.txt") Description = "rw grant on DemoDir; in-policy write should succeed" }, @{ Name = "fs-readonly"; PolicyFile = Join-Path $policyDir "fs-readonly.yaml" + SandboxId = "ro" Backends = "both"; Kind = "deny" ControlTarget = (Join-Path $DemoDir "fs-readonly-control.txt") DenyTarget = (Join-Path $roSrc "fs-readonly-denied.txt") @@ -375,16 +446,14 @@ try { }, @{ Name = "fs-default-deny"; PolicyFile = Join-Path $policyDir "fs-empty.yaml" + SandboxId = "fd" Backends = "process_container"; Kind = "deny" - # share_dir (DemoDir) is mapped rw by design, so it is NOT a valid deny - # probe. Prove default-deny by writing to an ungranted, out-of-share path; - # a control write to the share proves the agent actually ran. - ControlTarget = (Join-Path $DemoDir "fs-default-deny-control.txt") DenyTarget = (Join-Path $denyProbe "fs-default-deny-denied.txt") - Description = "empty policy; ungranted write denied; share control write succeeds" + Description = "empty policy; ungranted write denied" }, @{ Name = "network-reject"; PolicyFile = Join-Path $policyDir "network-reject.yaml" + SandboxId = "net" Backends = "both"; Kind = "create-fail" Description = "network_policies rule causes sandbox create to fail (no live backend needed)" } @@ -439,10 +508,15 @@ try { Remove-Item $sc.PosTarget -Force -ErrorAction SilentlyContinue $command = @($cmdExe, "/c", "echo ok 1> $($sc.PosTarget.Replace('\', '/'))") } elseif ($sc.Kind -eq "deny") { - Remove-Item $sc.ControlTarget, $sc.DenyTarget -Force -ErrorAction SilentlyContinue - $control = $sc.ControlTarget.Replace('\', '/') + Remove-Item $sc.DenyTarget -Force -ErrorAction SilentlyContinue $denied = $sc.DenyTarget.Replace('\', '/') - $command = @($cmdExe, "/c", "echo ok 1> $control & echo denied 1> $denied") + if ($sc.ControlTarget) { + Remove-Item $sc.ControlTarget -Force -ErrorAction SilentlyContinue + $control = $sc.ControlTarget.Replace('\', '/') + $command = @($cmdExe, "/c", "echo ok 1> $control & echo denied 1> $denied") + } else { + $command = @($cmdExe, "/c", "echo denied 1> $denied") + } } else { $command = @($cmdExe, "/c", "exit 0") } @@ -453,13 +527,6 @@ try { 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 - } Render-Toml # Preserve the exact rendered config + policy fixture used for this scenario. @@ -470,22 +537,27 @@ try { Info "gateway pid $($gw.Id)" Register-Cli - # Unique per-run sandbox name; pre-delete for belt-and-suspenders. - $sandboxName = "$($sc.Name)-$runId" - try { & $cli sandbox delete $sandboxName 2>&1 | Out-Null } catch {} + # Unique per-run sandbox name within the 19-character routable-name limit. + $sandboxName = "mxc-$($sc.SandboxId)-$runId" + try { Invoke-NativeCaptured $cli @("sandbox", "delete", $sandboxName) | Out-Null } catch {} # Run sandbox create. Its exit status is only authoritative for the # create-fail scenario; artifacts score workload scenarios. $createOut = $null; $createExitCode = 0 try { - $createOut = & $cli sandbox create --name $sandboxName --policy $sc.PolicyFile --driver-config-json $driverConfigArg --no-tty 2>&1 - $createExitCode = $LASTEXITCODE + $createResult = Invoke-NativeCaptured $cli @( + "sandbox", "create", "--name", $sandboxName, + "--policy", [string]$sc.PolicyFile, + "--driver-config-json", $driverConfig, + "--no-tty" + ) + $createOut = $createResult.Output + $createExitCode = $createResult.ExitCode } catch { $createOut = $_.Exception.Message; $createExitCode = 1 } $createOutStr = ($createOut -join "`n") Info "create exit: $createExitCode (not used for scoring on non-create-fail scenarios)" - try { & $cli sandbox delete $sandboxName 2>&1 | Out-Null } catch {} $gwText = (Get-Content $gwLog, $gwErrLog -Raw -ErrorAction SilentlyContinue) -join "`n" @@ -548,9 +620,12 @@ try { $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "deny target present (not enforced)" } } } else { - # No control target (empty policy): give the denied write a moment, then assert absent. + # An empty policy has no writable control path. Require both an absent + # artifact and an explicit driver launch message so launch failures + # cannot false-pass the denial. Start-Sleep -Seconds 3 $denyPresent = Test-Path $sc.DenyTarget + $gwText = (Get-Content $gwLog, $gwErrLog -Raw -ErrorAction SilentlyContinue) -join "`n" if ($denyPresent) { Bad "$($sc.Name): denied write was NOT blocked (artifact present)" $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "deny target present (not enforced)" } @@ -558,6 +633,10 @@ try { Bad "$($sc.Name): artifact absent but agent failed to launch - inconclusive" Info "createOut: $createOutStr" $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "agent launch failed (inconclusive)" } + } elseif (-not (Launch-Succeeded $gwText)) { + Bad "$($sc.Name): artifact absent but no agent-launch evidence was recorded - inconclusive" + Info "createOut: $createOutStr" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "agent launch not confirmed (inconclusive)" } } else { Ok "$($sc.Name): write correctly denied (artifact absent, agent launched)" $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "deny absent (default-deny enforced)" } @@ -569,6 +648,10 @@ try { Info "leaving gateway pid $($gw.Id) running (-KeepRunning); stopping after the first scenario so the next one doesn't collide on port $Port" break } else { + # Keep the sandbox alive until artifact-based scoring finishes. + # Real MXC startup is asynchronous and can otherwise be canceled + # before the workload writes its positive/control proof. + try { Invoke-NativeCaptured $cli @("sandbox", "delete", $sandboxName) | Out-Null } catch {} Stop-Gw $gw $gw = $null } @@ -619,8 +702,8 @@ Files in this bundle ($resultDir): policy..yaml the exact sandbox policy fixture used per scenario What PASS means: every non-skipped scenario met its expected verdict - positive -writes produced their artifact, deny writes were blocked (with a control write -proving the agent ran), and the network-reject scenario was refused by policy. +writes produced their artifact, deny writes were blocked with either a control +write or driver-launch evidence, and network-reject was refused by policy. "@ Set-Content -Path (Join-Path $resultDir "summary.txt") -Value $summary -Encoding UTF8 Write-Host $summary -ForegroundColor ($(if ($verdict -eq "PASS") { "Green" } else { "Red" }))