From 4a9c36213f43022eaeb9eed03e3531a13c454f57 Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:04:45 -0700 Subject: [PATCH 01/17] Give wxc-exec a typed error and warning contract Entry-point failures printed a bare line and exited 1, which a caller could not tell apart from a typed policy rejection, and warnings were written as unstructured stderr text. Classify every wxc-exec failure by whose problem it is, emit it as a typed JSON envelope, and carry warnings on ScriptResponse. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/core/mxc_common/config_parser.rs | 24 +- src/mxc-sdk/src/core/mxc_common/error.rs | 69 ++++++ src/mxc-sdk/src/core/mxc_common/models.rs | 103 +++++++-- src/mxc-sdk/src/core/mxc_common/mxc_error.rs | 25 +++ .../src/core/mxc_common/script_runner.rs | 65 +++++- src/mxc-sdk/src/core/mxc_engine/error.rs | 49 +++- src/tools/wxc/src/main.rs | 210 +++++++++--------- .../lib/WinProcessContainer.Common.ps1 | 16 +- 8 files changed, 424 insertions(+), 137 deletions(-) diff --git a/src/mxc-sdk/src/core/mxc_common/config_parser.rs b/src/mxc-sdk/src/core/mxc_common/config_parser.rs index a717aa457..d017fc561 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_parser.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_parser.rs @@ -22,9 +22,10 @@ use crate::mxc_contract::{probe_version, supported_versions, ContractVersion, Ve use std::fs; /// Categorised error from `load_mxc_request`. The `wxc-exec` driver uses the -/// variant to choose the failure-output convention: state-aware failures -/// emit a JSON `{"error": ...}` envelope on stdout, while one-shot and -/// pre-discrimination failures keep the existing diagnostic-on-stderr path. +/// variant to choose where the failure is written: state-aware failures emit a +/// JSON `{"error": ...}` envelope on stdout, because there the envelope *is* +/// the operation's response document, while one-shot and pre-discrimination +/// failures emit theirs on stderr alongside the diagnostics. #[derive(Debug)] pub enum ParseError { /// I/O, base64-decode, or top-level JSON parse failure — the input could @@ -42,6 +43,23 @@ pub enum ParseError { StateAware(MxcError), } +impl ParseError { + /// The typed error this failure is reported to the caller as. + /// + /// Every variant resolves to a code, so a request that never reached a + /// backend is still classified rather than being reported as an + /// unexplained non-zero exit. + pub fn to_mxc_error(&self) -> MxcError { + match self { + ParseError::Decode(error) + | ParseError::Version(error) + | ParseError::OneShot(error) + | ParseError::OneShotMalformed(error) => error.to_mxc_error(), + ParseError::StateAware(error) => error.clone(), + } + } +} + #[derive(Debug, Clone, Copy)] enum ErrorOutput { Primary, diff --git a/src/mxc-sdk/src/core/mxc_common/error.rs b/src/mxc-sdk/src/core/mxc_common/error.rs index 7e4cf7473..9cd48cbf9 100644 --- a/src/mxc-sdk/src/core/mxc_common/error.rs +++ b/src/mxc-sdk/src/core/mxc_common/error.rs @@ -3,6 +3,8 @@ use thiserror::Error; +use crate::mxc_common::mxc_error::{MxcError, MxcErrorCode}; + #[derive(Debug, Error)] pub enum WxcError { #[error("Configuration parse error: {0}")] @@ -42,6 +44,37 @@ impl From for WxcError { } } +impl WxcError { + /// The wire code this failure is reported under. + /// + /// The grouping is by *whose* problem it is, because that is what decides + /// what the caller can do next: fix the request, fix the host, or report a + /// bug. `BfsNotAvailable` is the one that is easy to get wrong -- nothing + /// the caller writes makes `bfscfg.exe` appear, so it is a property of the + /// host rather than a judgement of the policy. + pub fn error_code(&self) -> MxcErrorCode { + match self { + WxcError::ConfigParse(_) | WxcError::StringConversion(_) => { + MxcErrorCode::MalformedRequest + } + WxcError::Validation(_) => MxcErrorCode::PolicyValidation, + WxcError::BfsNotAvailable => MxcErrorCode::BackendUnavailable, + WxcError::Process(_) + | WxcError::Io(_) + | WxcError::Firewall(_) + | WxcError::FilesystemPolicy(_) + | WxcError::Initialization(_) + | WxcError::NetworkProxy(_) => MxcErrorCode::BackendError, + } + } + + /// Convert to the typed error carried on the wire, keeping the `Display` + /// text as the message so the variant's prefix survives. + pub fn to_mxc_error(&self) -> MxcError { + MxcError::new(self.error_code(), self.to_string()) + } +} + impl From for WxcError { fn from(err: std::io::Error) -> Self { WxcError::Io(err.to_string()) @@ -62,3 +95,39 @@ pub const DENIED_PATHS_FEATURE_DISABLED_MSG: &str = QueryProcessSecurityEnvironmentSupport. Run on a build with \ BaseContainer deny support, or use the ProcessContainer dispatcher so it can select \ the AppContainer + DACL fallback (which enforces deniedPaths via DENY ACEs)."; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_missing_host_tool_is_not_reported_as_a_bad_policy() { + assert_eq!( + WxcError::BfsNotAvailable.error_code(), + MxcErrorCode::BackendUnavailable + ); + assert_eq!( + WxcError::Validation("denied".into()).error_code(), + MxcErrorCode::PolicyValidation + ); + } + + #[test] + fn an_unreadable_request_is_told_apart_from_a_refused_one() { + assert_eq!( + WxcError::ConfigParse("bad json".into()).error_code(), + MxcErrorCode::MalformedRequest + ); + assert_eq!( + WxcError::Io("disk gone".into()).error_code(), + MxcErrorCode::BackendError + ); + } + + #[test] + fn conversion_keeps_the_variants_prefix_in_the_message() { + let error = WxcError::Validation("portRange is empty".into()).to_mxc_error(); + assert_eq!(error.code, MxcErrorCode::PolicyValidation); + assert_eq!(error.message, "Validation error: portRange is empty"); + } +} diff --git a/src/mxc-sdk/src/core/mxc_common/models.rs b/src/mxc-sdk/src/core/mxc_common/models.rs index 12f3be0f0..8817c1080 100644 --- a/src/mxc-sdk/src/core/mxc_common/models.rs +++ b/src/mxc-sdk/src/core/mxc_common/models.rs @@ -1156,9 +1156,8 @@ pub enum FailurePhase { /// the VM/sandbox bring-up, or a transient resource contention (e.g. a /// single-instance backend already running). Generally worth retrying. LaunchFailed, - /// The request cannot be honored and will not succeed on a blind retry - /// without changing the input or host: a policy rejection, or a missing - /// host prerequisite (backend/runtime not installed). + /// The request itself is refusable: the policy asks for something the + /// selected backend cannot enforce. Only a changed request can succeed. Rejected, /// The launch command succeeded but the guest/sandbox infrastructure failed /// before or while running user code (agent rendezvous, channel connect, or @@ -1171,26 +1170,38 @@ pub enum FailurePhase { /// can detect a timeout uniformly across backends rather than inferring it /// from `exit_code == -1` (which collides with other failures). Timeout, - /// The selected containment backend is unavailable on this host: the API is - /// missing, or present but not usable (e.g. feature-disabled). Distinct from - /// [`LaunchFailed`] so callers can fall back to a lower tier rather than - /// hard-fail. + /// The host cannot provide the selected containment backend at all: the API + /// is missing, the runtime is not installed, or an OS feature it depends on + /// is disabled. No change to the request can satisfy it — the host has to + /// be provisioned, or a different backend selected. Distinct from + /// [`LaunchFailed`], which is a backend that is present and failed. BackendUnavailable, } impl FailurePhase { /// The wire error code a failure in this phase is reported as. /// - /// [`Rejected`](FailurePhase::Rejected) is the one phase a caller can act on - /// by changing the request, so it is the only one that earns a code of its - /// own; every other failure is an infrastructure problem the caller cannot - /// distinguish usefully and stays `backend_error`. + /// Two phases earn a code of their own because they tell the caller which + /// side is at fault: [`Rejected`](FailurePhase::Rejected) blames the + /// request, and [`BackendUnavailable`](FailurePhase::BackendUnavailable) + /// blames the host. Every other failure is a runtime fault the caller + /// cannot distinguish usefully and stays `backend_error`. pub fn error_code(&self) -> MxcErrorCode { match self { FailurePhase::Rejected => MxcErrorCode::PolicyValidation, + FailurePhase::BackendUnavailable => MxcErrorCode::BackendUnavailable, _ => MxcErrorCode::BackendError, } } + + /// The process exit status a failure in this phase terminates with. + /// + /// Derived from [`error_code`](FailurePhase::error_code) so a response and + /// an entry-point failure carrying the same code always exit alike: a + /// refusal exits 1, and a host or runtime fault exits -1. + pub fn failure_exit_code(&self) -> i32 { + self.error_code().exit_code() + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1208,6 +1219,11 @@ pub struct ScriptResponse { /// Indicates at what phase the failure occurred. #[serde(default)] pub failure_phase: FailurePhase, + /// Non-fatal diagnostics raised while building or running the sandbox, + /// carried on the response so a programmatic caller can filter them + /// instead of scraping stderr. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec, /// Structured metadata produced after the sandboxed process exits. #[serde(default, skip_serializing_if = "Option::is_none")] pub output_metadata: Option>, @@ -1222,6 +1238,7 @@ impl Default for ScriptResponse { error_message: String::new(), extended_error: String::new(), failure_phase: FailurePhase::None, + warnings: Vec::new(), output_metadata: None, } } @@ -1297,12 +1314,27 @@ impl ScriptResponse { /// envelope. pub fn rejected(msg: &str) -> Self { ScriptResponse { - exit_code: 1, + exit_code: FailurePhase::Rejected.failure_exit_code(), error_message: msg.to_string(), failure_phase: FailurePhase::Rejected, ..Default::default() } } + + /// Create a response for a backend this host cannot provide. + /// + /// Reports `backend_unavailable`, which tells the caller the host is at + /// fault: no change to the request will satisfy it. Like + /// [`ScriptResponse::rejected`] nothing ran, so the message is left to the + /// envelope rather than copied into `standard_err`. + pub fn unavailable(msg: &str) -> Self { + ScriptResponse { + exit_code: FailurePhase::BackendUnavailable.failure_exit_code(), + error_message: msg.to_string(), + failure_phase: FailurePhase::BackendUnavailable, + ..Default::default() + } + } } #[cfg(test)] @@ -1310,18 +1342,21 @@ mod tests { use super::*; #[test] - fn only_rejection_earns_a_caller_actionable_error_code() { + fn each_caller_actionable_phase_earns_its_own_error_code() { assert_eq!( FailurePhase::Rejected.error_code(), MxcErrorCode::PolicyValidation ); + assert_eq!( + FailurePhase::BackendUnavailable.error_code(), + MxcErrorCode::BackendUnavailable + ); for phase in [ FailurePhase::None, FailurePhase::LaunchFailed, FailurePhase::PostLaunchFailed, FailurePhase::ProcessExited, FailurePhase::Timeout, - FailurePhase::BackendUnavailable, ] { assert_eq!( phase.error_code(), @@ -1331,6 +1366,46 @@ mod tests { } } + #[test] + fn only_a_refusal_exits_one() { + assert_eq!(FailurePhase::Rejected.failure_exit_code(), 1); + for phase in [ + FailurePhase::None, + FailurePhase::LaunchFailed, + FailurePhase::PostLaunchFailed, + FailurePhase::ProcessExited, + FailurePhase::Timeout, + FailurePhase::BackendUnavailable, + ] { + assert_eq!(phase.failure_exit_code(), -1, "phase {phase:?}"); + } + } + + #[test] + fn an_unavailable_host_is_told_apart_from_a_refusal_and_a_crash() { + let unavailable = ScriptResponse::unavailable("WSL is not installed"); + + assert_eq!(unavailable.exit_code, -1); + assert_eq!( + unavailable.failure_phase.error_code(), + MxcErrorCode::BackendUnavailable + ); + assert!(unavailable.standard_err.is_empty()); + } + + #[test] + fn warnings_are_omitted_from_the_wire_when_there_are_none() { + let quiet = serde_json::to_string(&ScriptResponse::default()).unwrap(); + assert!(!quiet.contains("warnings"), "{quiet}"); + + let noisy = serde_json::to_string(&ScriptResponse { + warnings: vec!["dacl augmentation fell back".to_string()], + ..Default::default() + }) + .unwrap(); + assert!(noisy.contains("dacl augmentation fell back"), "{noisy}"); + } + #[test] fn rejection_exits_one_and_leaves_stderr_to_the_envelope() { let rejected = ScriptResponse::rejected("policy not supported"); diff --git a/src/mxc-sdk/src/core/mxc_common/mxc_error.rs b/src/mxc-sdk/src/core/mxc_common/mxc_error.rs index 756e65df2..cfa0bf3e6 100644 --- a/src/mxc-sdk/src/core/mxc_common/mxc_error.rs +++ b/src/mxc-sdk/src/core/mxc_common/mxc_error.rs @@ -49,6 +49,19 @@ impl MxcErrorCode { Self::BackendError => "backend_error", } } + + /// The process exit status a failure with this code terminates with. + /// + /// Every caller-fault code exits 1: the request asked for something that + /// will not be done, and only a changed request can succeed. A host fault + /// (`backend_unavailable`) and a runtime fault (`backend_error`) exit -1, + /// and are told apart by the code rather than by the status. + pub fn exit_code(self) -> i32 { + match self { + Self::BackendUnavailable | Self::BackendError => -1, + _ => 1, + } + } } impl std::fmt::Display for MxcErrorCode { @@ -187,6 +200,18 @@ impl MxcError { self.api_failure.as_ref().map(|f| f.operation.as_str()) } + /// Serialise to the JSON response envelope callers parse. + /// + /// Falls back to a minimal hand-written envelope if serialisation itself + /// fails, so a consumer that requires `error.code` still parses something. + pub fn to_envelope_string(&self) -> String { + let envelope: ResponseEnvelope<()> = ResponseEnvelope::from_error(self); + serde_json::to_string(&envelope).unwrap_or_else(|_| { + r#"{"error":{"code":"backend_error","message":"failed to serialise error envelope"}}"# + .to_string() + }) + } + /// The underlying platform status, when known. Never present without /// [`MxcError::operation`]. pub fn native_code(&self) -> Option<&str> { diff --git a/src/mxc-sdk/src/core/mxc_common/script_runner.rs b/src/mxc-sdk/src/core/mxc_common/script_runner.rs index 7e94f4365..8bfcf677b 100644 --- a/src/mxc-sdk/src/core/mxc_common/script_runner.rs +++ b/src/mxc-sdk/src/core/mxc_common/script_runner.rs @@ -3,6 +3,7 @@ use crate::mxc_common::logger::Logger; use crate::mxc_common::models::{ExecutionRequest, ScriptResponse}; +use crate::mxc_common::mxc_error::MxcError; use crate::mxc_common::validator::{ validate_common, validate_network_policy_support, NetworkPolicySupport, }; @@ -97,7 +98,8 @@ pub fn handle_dry_run_exit(response: &ScriptResponse, logger: &mut Logger) -> ! /// /// The `code` comes from the response's /// [`FailurePhase`](crate::mxc_common::models::FailurePhase), so a refused policy is -/// reported as `policy_validation` rather than being flattened into the generic +/// reported as `policy_validation` and a host that cannot provide the backend +/// as `backend_unavailable`, rather than both being flattened into the generic /// `backend_error` every other failure uses. pub fn emit_backend_error_envelope(response: &ScriptResponse) { if response.exit_code == 0 || response.error_message.is_empty() { @@ -119,6 +121,43 @@ pub fn emit_backend_error_envelope(response: &ScriptResponse) { } } +/// The stderr lines a run's warnings are emitted as, one JSON object each. +/// +/// Kept separate from [`emit_warning_envelopes`] so the exact bytes a caller +/// has to parse are testable without capturing the process's stderr. +fn warning_envelope_lines(warnings: &[String]) -> Vec { + warnings + .iter() + .filter(|warning| !warning.trim().is_empty()) + .filter_map(|warning| { + serde_json::to_string(&serde_json::json!({ "warning": { "message": warning } })).ok() + }) + .collect() +} + +/// Emit each warning as its own structured JSON line on stderr. +/// +/// Warnings are reported on success as well as on failure, so this is separate +/// from [`emit_backend_error_envelope`] rather than folded into it. One object +/// per line keeps the stream parseable a line at a time, matching the error +/// envelope. +pub fn emit_warning_envelopes(warnings: &[String]) { + for line in warning_envelope_lines(warnings) { + eprintln!("{line}"); + } +} + +/// Emit a typed JSON error envelope on stderr for a failure raised before any +/// [`ScriptResponse`] exists, then exit with the status its code implies. +/// +/// Shared by every executor entry point. Without it such a failure prints a +/// bare line and exits 1, which a caller cannot tell from a typed policy +/// rejection. +pub fn emit_mxc_error_exit(error: &MxcError) -> ! { + eprintln!("{}", error.to_envelope_string()); + std::process::exit(error.code.exit_code()); +} + #[cfg(test)] mod tests { use super::get_timeout_milliseconds; @@ -164,6 +203,30 @@ mod tests { }); } + #[test] + fn warning_lines_are_one_parseable_object_each() { + let lines = super::warning_envelope_lines(&[ + "dacl augmentation fell back".to_string(), + "quoted \"name\" and \\ backslash".to_string(), + ]); + + assert_eq!( + lines[0], + r#"{"warning":{"message":"dacl augmentation fell back"}}"# + ); + // A warning is free-form text, so it has to survive JSON escaping. + let parsed: serde_json::Value = serde_json::from_str(&lines[1]).unwrap(); + assert_eq!( + parsed["warning"]["message"], + "quoted \"name\" and \\ backslash" + ); + } + + #[test] + fn blank_warnings_do_not_reach_the_stream() { + assert!(super::warning_envelope_lines(&[String::new(), " ".to_string()]).is_empty()); + } + #[test] fn dry_run_summary_reports_why_validation_failed() { use crate::mxc_common::models::ScriptResponse; diff --git a/src/mxc-sdk/src/core/mxc_engine/error.rs b/src/mxc-sdk/src/core/mxc_engine/error.rs index 9135ecb22..bfcb3e904 100644 --- a/src/mxc-sdk/src/core/mxc_engine/error.rs +++ b/src/mxc-sdk/src/core/mxc_engine/error.rs @@ -4,7 +4,7 @@ //! The SDK's own error type — a crate-owned facade over the internal //! `mxc_common` error, so the public API never exposes the foundation crate. -use crate::mxc_common::mxc_error::{MxcError, MxcErrorCode}; +use crate::mxc_common::mxc_error::{ApiFailure, MxcError, MxcErrorCode}; /// Error codes returned by the SDK, corresponding one-for-one to the /// snake_case codes reported by MXC. @@ -50,6 +50,26 @@ impl std::fmt::Display for ErrorCode { } } +impl ErrorCode { + /// The internal code this facade code stands for. + fn to_internal(self) -> MxcErrorCode { + match self { + Self::MalformedRequest => MxcErrorCode::MalformedRequest, + Self::UnsupportedContainment => MxcErrorCode::UnsupportedContainment, + Self::UnsupportedPhase => MxcErrorCode::UnsupportedPhase, + Self::BackendUnavailable => MxcErrorCode::BackendUnavailable, + Self::MalformedId => MxcErrorCode::MalformedId, + Self::StaleId => MxcErrorCode::StaleId, + Self::NotProvisioned => MxcErrorCode::NotProvisioned, + Self::NotStarted => MxcErrorCode::NotStarted, + Self::AlreadyStarted => MxcErrorCode::AlreadyStarted, + Self::AlreadyStopped => MxcErrorCode::AlreadyStopped, + Self::PolicyValidation => MxcErrorCode::PolicyValidation, + Self::BackendError => MxcErrorCode::BackendError, + } + } +} + impl From for ErrorCode { fn from(code: MxcErrorCode) -> Self { match code { @@ -147,11 +167,36 @@ impl From for Error { } } +impl Error { + /// Recover the internal error this facade error stands for, so an + /// in-repo binary can emit the typed envelope and exit status its code + /// implies. + /// + /// Deliberately an inherent method rather than `impl From for + /// MxcError`: a second `MxcError: From<_>` impl makes `?` ambiguous + /// wherever the error type is inferred. + /// + /// `MxcError::details` has no facade counterpart and does not survive the + /// round trip. + #[doc(hidden)] + pub fn into_mxc_error(self) -> MxcError { + let mut error = MxcError::new(self.code.to_internal(), self.message); + if let Some(operation) = self.operation { + let mut failure = ApiFailure::new(operation); + failure.native_code = self.native_code; + error = error.with_api_failure(failure); + } + if let Some(remediation) = self.remediation { + error = error.with_remediation(remediation); + } + error + } +} + #[cfg(test)] mod tests { use super::*; use crate::mxc_common::mxc_error::{ApiFailure as InnerFailure, MxcError}; - /// The conversion carries the API detail across, rather than keeping only /// the code and message. /// diff --git a/src/tools/wxc/src/main.rs b/src/tools/wxc/src/main.rs index be8a0ea9c..5383334f6 100644 --- a/src/tools/wxc/src/main.rs +++ b/src/tools/wxc/src/main.rs @@ -17,8 +17,8 @@ use mxc_sdk::mxc_common::diagnostic::DiagnosticConfig; use mxc_sdk::mxc_common::error::WxcError; use mxc_sdk::mxc_common::logger::{Logger, Mode}; use mxc_sdk::mxc_common::models::{ContainmentBackend, ExecutionRequest, ScriptResponse}; -use mxc_sdk::mxc_common::mxc_error::{MxcError, MxcErrorCode, ResponseEnvelope}; -use mxc_sdk::mxc_common::script_runner::{handle_dry_run_exit, ScriptRunner}; +use mxc_sdk::mxc_common::mxc_error::{MxcError, MxcErrorCode}; +use mxc_sdk::mxc_common::script_runner::{emit_mxc_error_exit, handle_dry_run_exit, ScriptRunner}; use mxc_sdk::mxc_common::state_aware_dispatch::{resolve_backend, DispatchOutcome}; use mxc_sdk::mxc_common::state_aware_request::{MxcRequest, ParsedStateAwareRequest, Phase}; use mxc_sdk::mxc_common::telemetry; @@ -572,9 +572,7 @@ fn run_state_aware_main( diagnostic_logger.log_audit_event(&record); } } - for warning in logger.take_warnings() { - eprintln!("{warning}"); - } + mxc_sdk::mxc_common::script_runner::emit_warning_envelopes(&logger.take_warnings()); let buffered = logger.get_buffer().to_string(); if !buffered.is_empty() { eprint!("{}", buffered); @@ -710,9 +708,14 @@ fn finalize_state_aware_outcome( } } +/// Where a request failure is written, which differs by request shape. enum RequestErrorRoute<'a> { - Diagnostic, - Envelope(&'a MxcError), + /// One-shot: a typed envelope on stderr, beside the diagnostics, because + /// stdout belongs to the script's own output. + Stderr(MxcError), + /// State-aware: the envelope *is* the operation's response document, so it + /// goes to stdout where the caller parses the result. + Stdout(&'a MxcError), } fn request_error_route(error: &ParseError) -> RequestErrorRoute<'_> { @@ -720,8 +723,8 @@ fn request_error_route(error: &ParseError) -> RequestErrorRoute<'_> { ParseError::Decode(_) | ParseError::Version(_) | ParseError::OneShot(_) - | ParseError::OneShotMalformed(_) => RequestErrorRoute::Diagnostic, - ParseError::StateAware(e) => RequestErrorRoute::Envelope(e), + | ParseError::OneShotMalformed(_) => RequestErrorRoute::Stderr(error.to_mxc_error()), + ParseError::StateAware(e) => RequestErrorRoute::Stdout(e), } } @@ -730,10 +733,8 @@ fn reject_state_aware_without_operation(logger: &mut Logger) -> ! { remove 'phase' and 'sandboxId' from the config JSON"; let error = ParseError::OneShot(WxcError::ConfigParse(message.to_string())); log_request_parse_rejection(logger, &error, None); - eprintln!("Request error"); - eprintln!("{message}"); eprint!("{}", logger.get_buffer()); - process::exit(1); + emit_mxc_error_exit(&MxcError::malformed_request(message)); } fn log_request_parse_rejection(logger: &mut Logger, error: &ParseError, phase: Option) { @@ -786,18 +787,9 @@ fn print_error_envelope(error: &MxcError) { /// Serialise `error` to its JSON response-envelope string. Split out of /// [`print_error_envelope`] so [`finalize_state_aware_outcome`] can build the -/// error-exit envelope without printing, and so both the print path and the -/// state-aware exit path share one serialisation (including the last-resort -/// fallback when the envelope itself fails to serialise). +/// error-exit envelope without printing. fn error_envelope_string(error: &MxcError) -> String { - let envelope: ResponseEnvelope<()> = ResponseEnvelope::from_error(error); - serde_json::to_string(&envelope).unwrap_or_else(|_| { - // Last-resort path: serialisation of the envelope itself failed — emit a - // minimal structurally-valid envelope so consumers that require - // `error.code` still parse something. - r#"{"error":{"code":"backend_error","message":"failed to serialise error envelope"}}"# - .to_string() - }) + error.to_envelope_string() } fn config_input(cli: &Cli) -> Option<(String, bool)> { @@ -1076,15 +1068,17 @@ fn main() { ) { Ok(MxcRequest::OneShot(request)) => request, Ok(MxcRequest::StateAware(_)) | Err(_) => { - eprintln!("Error: failed to load probe config"); eprint!("{}", probe_logger.get_buffer()); - process::exit(1); + emit_mxc_error_exit(&MxcError::malformed_request( + "failed to load probe config", + )); } }, Err(_) => { - eprintln!("Error: failed to load probe config"); eprint!("{}", probe_logger.get_buffer()); - process::exit(1); + emit_mxc_error_exit(&MxcError::malformed_request( + "failed to load probe config", + )); } } } else { @@ -1092,17 +1086,13 @@ fn main() { }; let output = match mxc_sdk::mxc_engine::probe_execution_request(Some(&request)) { Ok(output) => output, - Err(error) => { - eprintln!("Error: {}", error.message); - process::exit(1); - } + Err(error) => emit_mxc_error_exit(&error.into_mxc_error()), }; match mxc_sdk::process_container_common::probe::to_json_pretty(&output) { Ok(s) => println!("{s}"), - Err(e) => { - eprintln!("Error: probe serialization failed: {e}"); - process::exit(1); - } + Err(e) => emit_mxc_error_exit(&MxcError::backend_error(format!( + "probe serialization failed: {e}" + ))), } return; } @@ -1142,22 +1132,24 @@ fn main() { // Setup exits before any config is read, so a positional here is // most likely a runtime name given with a space instead of `=`. if let Some(stray) = &cli.config_path { - eprintln!( - "Error: --setup-hyperlight takes no config path; name runtimes with \ + emit_mxc_error_exit(&MxcError::malformed_request(format!( + "--setup-hyperlight takes no config path; name runtimes with \ --setup-hyperlight={stray}" - ); - process::exit(1); + ))); } #[cfg(all(feature = "hyperlight", target_arch = "x86_64"))] { // WHP is delay-loaded; check before setup boots a VM. #[cfg(target_os = "windows")] if !mxc_sdk::hyperlight_common::is_whp_available() { - eprintln!( - "Error: --setup-hyperlight requires Windows Hypervisor Platform (WHP). \ - Enable the HypervisorPlatform optional feature and reboot." + emit_mxc_error_exit( + &MxcError::backend_unavailable( + "--setup-hyperlight requires Windows Hypervisor Platform (WHP)", + ) + .with_remediation( + "Enable the HypervisorPlatform optional feature and reboot.", + ), ); - process::exit(1); } // Setup is an interactive install: the pull and the warm-up @@ -1165,27 +1157,24 @@ fn main() { let mut logger = Logger::new(Mode::Console); let runtimes = match mxc_sdk::hyperlight_common::parse_runtimes(runtime_names) { Ok(runtimes) => runtimes, - Err(msg) => { - eprintln!("Error: {msg}"); - process::exit(1); - } + Err(msg) => emit_mxc_error_exit(&MxcError::malformed_request(msg)), }; match mxc_sdk::hyperlight_common::setup(cli.force, &runtimes, &mut logger) { Ok(home) => { eprintln!("hyperlight setup: image home ready at {:?}", home); process::exit(0); } - Err(msg) => { - eprintln!("hyperlight setup failed: {msg}"); - process::exit(1); - } + Err(msg) => emit_mxc_error_exit(&MxcError::backend_error(format!( + "hyperlight setup failed: {msg}" + ))), } } #[cfg(not(all(feature = "hyperlight", target_arch = "x86_64")))] { let _ = runtime_names; - eprintln!("Error: --setup-hyperlight requires x86_64 (Hyperlight needs KVM or WHP)"); - process::exit(1); + emit_mxc_error_exit(&MxcError::backend_unavailable( + "--setup-hyperlight requires x86_64 (Hyperlight needs KVM or WHP)", + )); } } @@ -1197,10 +1186,9 @@ fn main() { { let image = match cli.image.as_deref() { Some(s) if !s.is_empty() => s, - _ => { - eprintln!("Error: --setup-wslc requires --image "); - process::exit(1); - } + _ => emit_mxc_error_exit(&MxcError::malformed_request( + "--setup-wslc requires --image ", + )), }; let mut logger = Logger::new(if cli.debug { Mode::Console @@ -1227,15 +1215,16 @@ fn main() { match result { Ok(()) => process::exit(0), Err(msg) => { - eprintln!("wslc setup failed: {msg}"); - process::exit(1); + emit_mxc_error_exit(&MxcError::backend_error(format!("wslc setup failed: {msg}"))) } } } #[cfg(not(feature = "wslc"))] { - eprintln!("Error: WSLC backend not compiled. Rebuild with --features wslc."); - process::exit(1); + emit_mxc_error_exit( + &MxcError::backend_unavailable("WSLC backend not compiled") + .with_remediation("Rebuild with --features wslc."), + ); } } @@ -1267,9 +1256,7 @@ fn main() { process::exit(1); } None => { - eprintln!("Request error"); - eprintln!("{}", error.into_error()); - process::exit(1); + emit_mxc_error_exit(&MxcError::malformed_request(error.into_error().to_string())); } }, None => { @@ -1286,8 +1273,7 @@ fn main() { eprint!("{}", logger.get_buffer()); process::exit(1); } - eprintln!("Error: {message}"); - process::exit(1); + emit_mxc_error_exit(&MxcError::malformed_request(message)); } None } @@ -1297,10 +1283,9 @@ fn main() { if cli.delete { let name = match cli.containername { Some(ref n) => n.as_str(), - None => { - eprintln!("Error: --containername is required with --delete"); - process::exit(1); - } + None => emit_mxc_error_exit(&MxcError::malformed_request( + "--containername is required with --delete", + )), }; let success = delete_app_container_profile(name, &mut logger); print!("{}", logger.get_buffer()); @@ -1369,15 +1354,16 @@ fn main() { Err(error) => { log_request_parse_rejection(&mut logger, &error, None); match request_error_route(&error) { - RequestErrorRoute::Diagnostic => { - eprint!("Request error\n{}", logger.get_buffer()); + RequestErrorRoute::Stderr(typed) => { + eprint!("{}", logger.get_buffer()); + emit_mxc_error_exit(&typed); } - RequestErrorRoute::Envelope(e) => { + RequestErrorRoute::Stdout(e) => { print_error_envelope(e); eprint!("{}", logger.get_buffer()); + process::exit(e.code.exit_code()); } } - process::exit(1); } }; @@ -1428,27 +1414,25 @@ fn main() { "containment", "", ); - eprintln!("Error: {message}"); telemetry::emit_early_exit_with_kind( telemetry_active, &request.containment, requested_sandbox_kind, telemetry::FailureReason::ConfigError, ); - process::exit(1); + emit_mxc_error_exit(&MxcError::policy_validation(message)); } let injected = apply_permissive_learning_mode(&mut request.policy.capabilities); let context = match audit::prepare_request(&mut request, config_file_path(&cli)) { Ok(context) => context, Err(message) => { - eprintln!("Error: {message}"); telemetry::emit_early_exit_with_kind( telemetry_active, &request.containment, requested_sandbox_kind, telemetry::FailureReason::ConfigError, ); - process::exit(1); + emit_mxc_error_exit(&MxcError::backend_error(message)); } }; audit_context = Some(context); @@ -1470,9 +1454,6 @@ fn main() { "process.commandLine", "", ); - eprintln!( - "Error: no command to run. Provide `process.commandLine` in the policy or pass the command as arguments after the config path." - ); eprint!("{}", logger.get_buffer()); telemetry::emit_early_exit_with_kind( telemetry_active, @@ -1480,7 +1461,9 @@ fn main() { requested_sandbox_kind, telemetry::FailureReason::ConfigError, ); - process::exit(1); + emit_mxc_error_exit(&MxcError::malformed_request( + "no command to run. Provide `process.commandLine` in the policy or pass the command as arguments after the config path.", + )); } // Inject learningModeLogging capability when diagnostic console is enabled. @@ -1589,14 +1572,14 @@ fn main() { resolved.runner } Err(e) => { + let error = e.into_mxc_error(); log_config_rejected( &mut logger, - RejectionReason::RunnerUnavailable, + rejection_reason_for(&error), request.containment.wire_name(), "containment", "", ); - eprintln!("error: {}", e.message); eprint!("{}", logger.get_buffer()); telemetry::emit_early_exit_with_kind( telemetry_active, @@ -1604,7 +1587,7 @@ fn main() { requested_sandbox_kind, telemetry::FailureReason::InitError, ); - process::exit(1); + emit_mxc_error_exit(&error); } }; @@ -1664,11 +1647,11 @@ fn main() { // deny-by-default under --audit). The logger only records these rather than // writing them itself, so that `mxc_engine` embedders don't get unannounced // writes to a terminal they own. wxc-exec *does* own its terminal, so it - // opts in here. Messages carry their own banner; print them verbatim. + // opts in here. Warnings travel on the response so a programmatic caller + // reads them from one structured place rather than scraping stderr. // Emitted before the dry-run branch below, which exits the process. - for warning in logger.warnings() { - eprintln!("{warning}"); - } + response.warnings.extend(logger.take_warnings()); + mxc_sdk::mxc_common::script_runner::emit_warning_envelopes(&response.warnings); #[cfg(target_os = "windows")] if let Err(error) = mxc_sdk::mxc_engine::emit_verbose_telemetry( @@ -1953,7 +1936,7 @@ mod tests { impl ResolveError { fn from_parse(e: ParseError) -> Self { - let envelope_routed = matches!(request_error_route(&e), RequestErrorRoute::Envelope(_)); + let envelope_routed = matches!(request_error_route(&e), RequestErrorRoute::Stdout(_)); let message = match e { ParseError::Decode(err) @@ -2043,28 +2026,33 @@ mod tests { #[test] fn request_error_route_matches_the_output_conventions() { - let decode = ParseError::Decode(WxcError::ConfigParse("decode".to_string())); - assert!(matches!( - request_error_route(&decode), - RequestErrorRoute::Diagnostic - )); - - let version = ParseError::Version(WxcError::ConfigParse("version".to_string())); - assert!(matches!( - request_error_route(&version), - RequestErrorRoute::Diagnostic - )); - - let oneshot = ParseError::OneShot(WxcError::ConfigParse("oneshot".to_string())); - assert!(matches!( - request_error_route(&oneshot), - RequestErrorRoute::Diagnostic - )); + // The stderr route still classifies: a one-shot parse failure blames + // the request, so it reports a code rather than an untyped exit. + for (case, error) in [ + ( + "decode", + ParseError::Decode(WxcError::ConfigParse("decode".to_string())), + ), + ( + "version", + ParseError::Version(WxcError::ConfigParse("version".to_string())), + ), + ( + "oneshot", + ParseError::OneShot(WxcError::ConfigParse("oneshot".to_string())), + ), + ] { + let RequestErrorRoute::Stderr(typed) = request_error_route(&error) else { + panic!("{case}: one-shot failures belong on stderr"); + }; + assert_eq!(typed.code, MxcErrorCode::MalformedRequest, "{case}"); + assert_eq!(typed.code.exit_code(), 1, "{case}"); + } let stateaware = ParseError::StateAware(MxcError::malformed_request("stateaware")); assert!(matches!( request_error_route(&stateaware), - RequestErrorRoute::Envelope(_) + RequestErrorRoute::Stdout(_) )); } @@ -2289,7 +2277,7 @@ mod tests { ) .unwrap_err(); assert!( - matches!(request_error_route(&error), RequestErrorRoute::Diagnostic), + matches!(request_error_route(&error), RequestErrorRoute::Stderr(_)), "{case}: pre-discrimination failures must remain stderr diagnostics" ); @@ -2328,7 +2316,7 @@ mod tests { &mut logger, ) .unwrap_err(); - let RequestErrorRoute::Envelope(error) = request_error_route(&error) else { + let RequestErrorRoute::Stdout(error) = request_error_route(&error) else { panic!("identified lifecycle requests must retain their JSON error envelope"); }; let envelope: serde_json::Value = @@ -2349,7 +2337,7 @@ mod tests { .unwrap_err(); assert!(matches!( request_error_route(&error), - RequestErrorRoute::Diagnostic + RequestErrorRoute::Stderr(_) )); assert!(logger.get_buffer().contains("duplicate field `telemetry`")); } diff --git a/tests/scripts/lib/WinProcessContainer.Common.ps1 b/tests/scripts/lib/WinProcessContainer.Common.ps1 index ae0be591f..19c1884ec 100644 --- a/tests/scripts/lib/WinProcessContainer.Common.ps1 +++ b/tests/scripts/lib/WinProcessContainer.Common.ps1 @@ -938,18 +938,22 @@ function Test-WasRejected { if ($both -match '(?i)create_process_failed|CreateProcessSecurityEnvironment failed') { return $false } - # Validation failures surface as config_parse or policy_validation. - if ($both -match '"code"\s*:\s*"backend_error"') { return $false } + # A runtime fault and a host that cannot provide the backend are both + # failures of the run, not judgements of the policy. + if ($both -match '"code"\s*:\s*"(backend_error|backend_unavailable)"') { return $false } # No tier could be built on this host, so the policy was never judged. # Emitted as a ConfigRejected event, which the typed checks above miss. if ($both -match '"reason"\s*:\s*"runner_unavailable"') { return $false } - # Positive evidence. wxc-exec prints this banner for every request it - # refuses, at parse stage and at backend validate. Requiring it means an - # unexplained non-zero exit is NOT counted as a rejection. + # Positive evidence. Every code below blames the request, so each one is a + # refusal; this mirrors the exit-1 set in MxcErrorCode::exit_code. The two + # text banners cover the paths that still print prose. Requiring one of + # these means an unexplained non-zero exit is NOT counted as a rejection. + $rejectionCodes = 'config_parse|policy_validation|malformed_request|' + + 'unsupported_containment|unsupported_phase|malformed_id' return [bool]($both -match '(?im)^\s*Request error\s*$' -or $both -match '(?i)Configuration parse error' -or - $both -match '"code"\s*:\s*"(config_parse|policy_validation)"') + $both -match ('"code"\s*:\s*"(' + $rejectionCodes + ')"')) } # The non-network counterpart of Get-NetVerdict's NORUN state. From 4f6c81d8a2baf8039f5cf3b733bf7a0e10566fbb Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:43:10 -0700 Subject: [PATCH 02/17] Classify every backend failure by which side can act on it Backends reported failures inconsistently: some flattened everything to a generic backend error, the Bubblewrap and LXC runners set no failure phase at all, and WSLC rejections exited -1 while ProcessContainer rejections exited 1. A caller could not tell a refused request from an unavailable host, and the PowerShell suites had to match on prose banners to find out. Classify each failure by whose problem it is and derive both the wire code and the exit code from that: the request is at fault Rejected policy_validation exit 1 the host is at fault BackendUnavailable backend_unavailable exit -1 the runtime faulted mid-run other phases backend_error exit -1 Every backend now sets a phase, and `ScriptResponse::failure_exit_code` is the single place the exit code comes from. Two consequences are worth calling out. A capability the host's Windows build cannot represent -- `enumeratePaths`, `hostLoopback='allow'` -- now reaches the wire as `policy_validation`, because dropping the field runs; it used to be reported as an unavailable backend, which told the caller to give up on the host. Conversely, a backend this build did not compile in now reports `backend_unavailable` from the one-shot path too, matching what the state-aware path already returned, because only a rebuilt executor can serve it. Add an optional `warnings` array to `ScriptResponse` so the executors stop interleaving advisory diagnostics with the error channel. It is boxed to keep the struct under clippy's `result_large_err` threshold, and omitted from the wire when a run produced none. Fixes #612 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/backends/bwrap/bubblewrap-backend.md | 15 + .../backends/hyperlight/hyperlight-backend.md | 11 +- docs/backends/lxc/lxc-backend.md | 15 + docs/backends/seatbelt/seatbelt-backend.md | 5 +- .../isolation-session/state-aware-rust.md | 11 +- .../architecture/container-lifecycle.md | 5 +- docs/development/architecture/versioning.md | 19 ++ .../bubblewrap/common/bwrap_runner.rs | 144 +++++++-- .../src/backends/hyperlight/common/mod.rs | 137 ++++++--- .../isolation_session/common/error.rs | 27 +- .../isolation_session/common/one_shot.rs | 47 ++- .../isolation_session/common/sandbox.rs | 74 +++-- .../src/backends/lxc/common/availability.rs | 9 + .../src/backends/lxc/common/lxc_runner.rs | 288 ++++++++++++++---- src/mxc-sdk/src/backends/nanvix/runner/mod.rs | 143 +++++++-- .../common/appcontainer_runner.rs | 63 +++- .../common/base_container_runner.rs | 63 +++- .../process_container/common/dispatcher.rs | 107 +++++++ .../seatbelt/common/seatbelt_runner.rs | 128 ++++++-- .../windows_sandbox/lifecycle/error.rs | 41 +-- .../windows_sandbox/lifecycle/state_aware.rs | 10 +- src/mxc-sdk/src/backends/wslc/common/error.rs | 30 +- .../wslc/common/wsl_container_runner.rs | 24 +- src/mxc-sdk/src/core/mxc_common/models.rs | 55 +++- src/mxc-sdk/src/core/mxc_engine/dispatch.rs | 2 +- src/mxc-sdk/src/core/mxc_engine/run.rs | 24 +- src/tools/lxc/src/main.rs | 40 +-- src/tools/mxc_darwin/src/main.rs | 30 +- src/tools/wxc/src/main.rs | 55 ++-- 29 files changed, 1283 insertions(+), 339 deletions(-) diff --git a/docs/backends/bwrap/bubblewrap-backend.md b/docs/backends/bwrap/bubblewrap-backend.md index df11c43a8..17e74567d 100644 --- a/docs/backends/bwrap/bubblewrap-backend.md +++ b/docs/backends/bwrap/bubblewrap-backend.md @@ -920,6 +920,21 @@ tests/scripts/run_bwrap_all_tests.sh Test configs are in `tests/configs/bubblewrap_*.json`. +## Troubleshooting + +Failures carry an error code: `policy_validation` (exit 1) when the request +asks for something the backend refuses, `backend_unavailable` (exit -1) when +the host cannot provide the backend at all, and `backend_error` (exit -1) for +setup and runtime faults. + +| Message | Meaning | +|---|---| +| `Bubblewrap (bwrap) is not installed or not on PATH` / version too old | The host has no usable Bubblewrap (`backend_unavailable`); install `bubblewrap` 0.5+ | +| `requires 'slirp4netns' on PATH` / `requires unprivileged user and network namespaces` | The host cannot provide the sandbox network (`backend_unavailable`) | +| `script_code is empty` / `must not carry credentials` / an egress or proxy refusal | The request is at fault (`policy_validation`); change it and retry | +| `failed to spawn bwrap` / `failed to allocate PTY` / proxy namespace start failure | The request was accepted but the launch faulted (`backend_error`) | +| `failed to wrap stdio pipes` | The sandbox started and then faulted (`backend_error`) | + ## Limitations - **Linux only** — Bubblewrap requires Linux kernel namespaces diff --git a/docs/backends/hyperlight/hyperlight-backend.md b/docs/backends/hyperlight/hyperlight-backend.md index 337c2d0bf..3bbf1e482 100644 --- a/docs/backends/hyperlight/hyperlight-backend.md +++ b/docs/backends/hyperlight/hyperlight-backend.md @@ -155,11 +155,18 @@ contain whitespace, `:` or brackets, and two mounts may not share one. ## Troubleshooting +Failures carry an error code: `policy_validation` (exit 1) when the request +asks for something the backend refuses, `backend_unavailable` (exit -1) when +the host cannot provide the backend at all, and `backend_error` (exit -1) for +setup and runtime faults. + | Message | Meaning | |---|---| -| `no hyperlight image found for the runtime` | No home holds that runtime; run `--setup-hyperlight=`, or set `MXC_HYPERLIGHT_HOME` | +| `hyperlight policy error: …` | The request is at fault (`policy_validation`); change it and retry | +| `no hyperlight image found for the runtime` | No home holds that runtime (`backend_unavailable`); run `--setup-hyperlight=`, or set `MXC_HYPERLIGHT_HOME` | | `holds a rootfs from another hyperlight-unikraft release` | The directory predates the crate the binary was built with; `--setup-hyperlight=` rebuilds it | -| `Hyperlight requires KVM` / `Hyperlight requires Windows Hypervisor Platform` | No hypervisor for this process (error code `backend_unavailable`): on Linux `/dev/kvm` is missing or not readable and writable, on Windows WHP is not enabled | +| `Hyperlight requires KVM` / `Hyperlight requires Windows Hypervisor Platform` | No hypervisor for this process (`backend_unavailable`): on Linux `/dev/kvm` is missing or not readable and writable, on Windows WHP is not enabled | +| `hyperlight setup error: …` / `hyperlight runtime error: …` | The run was accepted but faulted (`backend_error`) | | `execution timed out after N s` | The script overran `process.timeout` | Run with `--debug` to see the home chosen, restore and call timings, and diff --git a/docs/backends/lxc/lxc-backend.md b/docs/backends/lxc/lxc-backend.md index e951c716c..6478cb2ad 100644 --- a/docs/backends/lxc/lxc-backend.md +++ b/docs/backends/lxc/lxc-backend.md @@ -326,6 +326,21 @@ sudo firewall-cmd --get-zone-of-interface=lxcbr0 The zone query should answer the zone you assigned. +## Troubleshooting + +Failures carry an error code: `policy_validation` (exit 1) when the request +asks for something the backend refuses, `backend_unavailable` (exit -1) when +the host cannot provide the backend at all, and `backend_error` (exit -1) for +setup and runtime faults. + +| Message | Meaning | +|---|---| +| `the LXC runtime is not available on this host` | `lxc-ls --version` did not run (`backend_unavailable`); install the LXC tools | +| `sandboxes can only be launched on Linux` | Wrong OS for the backend (`backend_unavailable`) | +| `distribution and release are required` / a proxy, egress or inherited-stdio refusal | The request is at fault (`policy_validation`); change it and retry | +| `Failed to create container` / `Failed to start container` / `container '…' is already in use` | The request was accepted but the launch faulted (`backend_error`) | +| `did not receive an IPv4 address` / `Failed to discover the container init PID` | The container started and then faulted before the script ran (`backend_error`) | + ## Limitations - **Default-deny is not a containment boundary against the workload.** Both the diff --git a/docs/backends/seatbelt/seatbelt-backend.md b/docs/backends/seatbelt/seatbelt-backend.md index cb35eb515..67f10a3bb 100644 --- a/docs/backends/seatbelt/seatbelt-backend.md +++ b/docs/backends/seatbelt/seatbelt-backend.md @@ -596,11 +596,14 @@ once you can see the rules that were emitted. | A client is configured with `HTTP_PROXY` but reaches nothing | It's ignoring the proxy vars. Outbound is kernel-scoped to the proxy port, so it can't connect anywhere else | Use a proxy-aware client — see [Proxy support](#proxy-support-what-is-and-isnt-enforced) | | A test runner or build tool fails spawning workers | `nestedPty: false`, or its IPC socket sits under a `readonlyPaths`/`deniedPaths` entry | Leave `nestedPty` at `true`; put socket directories in `readwritePaths` | | The same config runs on Linux but is rejected on macOS | A `..` segment in a path — Seatbelt-only rejection | Pass the fully resolved path | +| `Seatbelt is unavailable on this host: /usr/bin/sandbox-exec is missing` | The install has no Apple sandbox tooling, so the backend cannot be provided (`backend_unavailable`, exit `-1`) | Run on a stock macOS install, or pick another backend | ### What gets rejected MXC refuses any config it cannot faithfully enforce, rather than quietly -approximating it. This is the complete list. +approximating it. This is the complete list. A refusal carries the +`policy_validation` error code and exit code `1`: nothing ran, and only a +changed config can succeed. One documented exception: `ingress.hostLoopback: "deny"` under `ingress.default: "allow"` is accepted with the container-to-host half enforced diff --git a/docs/development/architecture/backends/isolation-session/state-aware-rust.md b/docs/development/architecture/backends/isolation-session/state-aware-rust.md index f7ac7d06c..9303c2ca4 100644 --- a/docs/development/architecture/backends/isolation-session/state-aware-rust.md +++ b/docs/development/architecture/backends/isolation-session/state-aware-rust.md @@ -347,10 +347,13 @@ as `malformed_request`. Requests that pass the exact structural contract but violate a backend semantic invariant surface as `policy_validation`; a structurally valid but oversized `appId` is one such case. -On the **one-shot** surface the backend's typed policy variant is discarded -(`ScriptResponse::error`) and the envelope carries `error.code = -"backend_error"` with the reason in the message. A supplied `network.proxy` is -also structurally refused as `malformed_request`. +On the **one-shot** surface the backend's typed policy variant becomes +`ScriptResponse::rejected`, so the envelope carries `error.code = +"policy_validation"` and exits 1, matching the state-aware mapping. A supplied +`network.proxy` is also structurally refused as `malformed_request`. An +activation failure of the runtime API becomes `ScriptResponse::unavailable` +(`backend_unavailable`, exit -1); every other lifecycle failure stays +`backend_error`. ## Mode-specific fields diff --git a/docs/development/architecture/container-lifecycle.md b/docs/development/architecture/container-lifecycle.md index 76344f44a..77f063779 100644 --- a/docs/development/architecture/container-lifecycle.md +++ b/docs/development/architecture/container-lifecycle.md @@ -629,7 +629,10 @@ because a script's stderr may not end in a newline. `code` and `message` are always present. `code` is the machine-readable category a consumer branches on; `message` is the human-readable description, and for a failure raised by an underlying platform API it is that API's own message, passed through -verbatim rather than concatenated with the other fields. +verbatim rather than concatenated with the other fields. The executor's own exit +code follows `code`: a refused request exits `1`, and an unavailable host or a +runtime fault exits `-1`. See the error contract in +[versioning.md](versioning.md#error-contract). The three optional named fields carry structured failure detail. `operation` and `nativeCode` describe an underlying platform call; `remediation` describes the failure: diff --git a/docs/development/architecture/versioning.md b/docs/development/architecture/versioning.md index 9948187fd..846faae73 100644 --- a/docs/development/architecture/versioning.md +++ b/docs/development/architecture/versioning.md @@ -705,6 +705,25 @@ Negotiation failures are **typed and actionable** — never a silent fallback: unsupported field. Security policy is deterministic — no relaxation, no fuzzy fallback. +Every executor failure is classified by **which side can act on it**, and that +classification drives both the wire code and the process exit code: + +| Side at fault | Failure phase | Wire code | Exit code | +|---|---|---|---| +| The request — a different request succeeds | `Rejected` | `policy_validation` | `1` | +| The host — no request change helps | `BackendUnavailable` | `backend_unavailable` | `-1` | +| The runtime, during an accepted run | all other phases | `backend_error` | `-1` | + +A backend this build did not compile in reports `backend_unavailable`, since +only a rebuilt executor can serve it. A backend belonging to another operating +system reports `unsupported_containment`, since naming a supported backend +succeeds. Earlier phase-specific codes (`config_parse`, `malformed_request`, +`unsupported_phase`, `malformed_id`) are unchanged and also exit `1`. + +`ScriptResponse` carries an optional `warnings` array of diagnostic strings. +Warnings are advisory and never change the exit code; they are omitted from the +wire entirely when a run produced none. + The MXC ↔ OS contract therefore reports: which feature failed, whether it was a version mismatch or a runtime/capability unavailability (e.g. Hyper-V off), and what the user should do (upgrade OS, enable feature, change the config). diff --git a/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs b/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs index 2e5df33d0..8505085bb 100644 --- a/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs +++ b/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs @@ -25,7 +25,7 @@ use std::time::{Duration, Instant}; use crate::mxc_common::filesystem_symlink::resolve_through_symlinks; use crate::mxc_common::interruptible_reader::{wrap_pipe, InterruptibleReader, ReadCanceller}; use crate::mxc_common::logger::Logger; -use crate::mxc_common::models::{ExecutionRequest, ScriptResponse}; +use crate::mxc_common::models::{ExecutionRequest, FailurePhase, ScriptResponse}; use crate::mxc_common::sandbox_process::{ boxed_closer, cancel_and_join_discard, duplicate_and_take_native_stdio, group_kill, spawn_discard, take_boxed_read, take_boxed_write, wait_with_timeout, NativeStdio, PtySize, @@ -45,6 +45,18 @@ use crate::bwrap_common::{ const DNS_PORT: u16 = 53; +/// A runtime fault, tagged with the phase of the run it broke in. +/// +/// A refused request and an unusable host have their own constructors on +/// [`ScriptResponse`]; this covers the faults that stay `backend_error` and +/// only say *where* the run came apart. +fn response_with_phase(phase: FailurePhase, message: &str) -> ScriptResponse { + ScriptResponse { + failure_phase: phase, + ..ScriptResponse::error(message) + } +} + /// Bubblewrap sandbox runner. Uses only shared `ContainerPolicy` fields — /// no backend-specific config struct required. #[derive(Default)] @@ -114,13 +126,13 @@ impl SandboxBackend for BubblewrapScriptRunner { &normalized } Ok(None) => request, - Err(msg) => return Err(ScriptResponse::error(&msg)), + Err(msg) => return Err(ScriptResponse::rejected(&msg)), }; // Reject any policy path the invoking user cannot access, so the sandbox // never gains access the caller lacks. Runs after object normalization // so it sees the already-tightened intents. if let Err(msg) = crate::mxc_common::filesystem_access::check_delegation(&request.policy) { - return Err(ScriptResponse::error(&msg)); + return Err(ScriptResponse::rejected(&msg)); } // Resolve denied paths that traverse a symlink to their real host path // and classify each as a file/dir mask (see [`resolve_denied_paths`]). @@ -128,7 +140,7 @@ impl SandboxBackend for BubblewrapScriptRunner { // none). See docs/backends/bwrap/bubblewrap-backend.md. let plan = match resolve_denied_paths(&request.policy, logger) { Ok(plan) => plan, - Err(msg) => return Err(ScriptResponse::error(&msg)), + Err(msg) => return Err(ScriptResponse::rejected(&msg)), }; let resolved; let request = match plan.paths { @@ -146,7 +158,7 @@ impl SandboxBackend for BubblewrapScriptRunner { // The masks are built from the list above, not from the one the caller // wrote, so the pin conflict has to be judged against it too. if let Err(msg) = check_pin_against_denied_hosts(request) { - return Err(ScriptResponse::error(&msg)); + return Err(ScriptResponse::rejected(&msg)); } warn_resolver_mount_hint(request, logger); warn_cwd_mount_hint(request, logger); @@ -189,7 +201,7 @@ impl BubblewrapScriptRunner { // probe so config errors are reported deterministically even on // hosts without bwrap installed. if request.script_code.is_empty() { - return Err(ScriptResponse::error( + return Err(ScriptResponse::rejected( "script_code is empty — nothing to execute.", )); } @@ -217,7 +229,7 @@ impl BubblewrapScriptRunner { if crate::mxc_common::proxy_env::proxy_url_has_credentials(&url) { // Built from the redacted form so the rejection cannot become // the leak it is rejecting. - return Err(ScriptResponse::error(&format!( + return Err(ScriptResponse::rejected(&format!( "Bubblewrap: runtimeConfig.networkProxy must not carry credentials ('{}'). \ Bubblewrap passes the proxy URL to bwrap as a --setenv command-line \ argument, and process arguments are world-readable through \ @@ -233,10 +245,10 @@ impl BubblewrapScriptRunner { // Programmatic requests also need the parser's proxy-only restriction: // direct egress must not be accepted and then discarded at launch. if let Some(reason) = bwrap_command::proxy_with_egress_rejection(request) { - return Err(ScriptResponse::error(reason)); + return Err(ScriptResponse::rejected(reason)); } if let Some(reason) = bwrap_command::directional_network_rejection(request) { - return Err(ScriptResponse::error(reason)); + return Err(ScriptResponse::rejected(reason)); } // Proxy-only networking derives the sandbox-visible endpoint from the @@ -258,12 +270,12 @@ impl BubblewrapScriptRunner { if proxy_only { if let Some(address) = request.policy.network_proxy.address.as_ref() { if let Err(error) = proxy_network::SandboxProxy::check_without_resolving(address) { - return Err(ScriptResponse::error(&error)); + return Err(ScriptResponse::rejected(&error)); } if let Err(error) = proxy_network::check_hosts_pin_against_policy(address, &request.policy) { - return Err(ScriptResponse::error(&error)); + return Err(ScriptResponse::rejected(&error)); } // Repeated in `spawn` against the normalized policy. } @@ -277,7 +289,7 @@ impl BubblewrapScriptRunner { let egress_plan = if firewall_enforced { match network_rules::EgressPlan::for_request(request) { Ok(plan) => Some(plan), - Err(error) => return Err(ScriptResponse::error(&error)), + Err(error) => return Err(ScriptResponse::rejected(&error)), } } else { None @@ -287,7 +299,7 @@ impl BubblewrapScriptRunner { // builder emits — an old binary would otherwise fail at spawn time with // an opaque "unknown option" error. if let Err(err) = probe() { - return Err(ScriptResponse::error(&err.to_string())); + return Err(ScriptResponse::unavailable(&err.to_string())); } if proxy_only || firewall_enforced { // Proxy and firewall are mutually exclusive, so this names the one @@ -298,7 +310,7 @@ impl BubblewrapScriptRunner { proxy_network::PrivateNetworkUse::FirewallEnforcement }; if let Err(error) = proxy_network::probe_dependencies(use_case) { - return Err(ScriptResponse::error(&error)); + return Err(ScriptResponse::unavailable(&error)); } } @@ -457,7 +469,7 @@ impl BubblewrapScriptRunner { })?; Some( proxy_network::SandboxProxy::resolve(address) - .map_err(|error| ScriptResponse::error(&error))?, + .map_err(|error| ScriptResponse::rejected(&error))?, ) } else { None @@ -485,7 +497,9 @@ impl BubblewrapScriptRunner { request.script_timeout, ) { Ok(network) => Some(network), - Err(error) => return Err(ScriptResponse::error(&error)), + Err(error) => { + return Err(response_with_phase(FailurePhase::LaunchFailed, &error)) + } } } // Direct directional egress uses the same private namespace. @@ -498,7 +512,7 @@ impl BubblewrapScriptRunner { Some(plan) => plan, None => match network_rules::EgressPlan::for_request(request) { Ok(plan) => plan, - Err(error) => return Err(ScriptResponse::error(&error)), + Err(error) => return Err(ScriptResponse::rejected(&error)), }, }; let resolver = proxy_network::ResolverPin::for_host(&request.policy, logger); @@ -514,7 +528,9 @@ impl BubblewrapScriptRunner { warn_resolver_blocked_by_egress(&plan, network.resolver_pinned(), logger); Some(network) } - Err(error) => return Err(ScriptResponse::error(&error)), + Err(error) => { + return Err(response_with_phase(FailurePhase::LaunchFailed, &error)) + } } } None => None, @@ -534,7 +550,7 @@ impl BubblewrapScriptRunner { Ok(startup) => Some(startup), Err(error) => { stop_proxy_network(&mut proxy_network, logger); - return Err(ScriptResponse::error(&error)); + return Err(response_with_phase(FailurePhase::LaunchFailed, &error)); } }, None => None, @@ -578,9 +594,10 @@ impl BubblewrapScriptRunner { Ok(pty) => Some(pty), Err(error) => { stop_proxy_network(&mut proxy_network, logger); - return Err(ScriptResponse::error(&format!( - "Bubblewrap: failed to allocate PTY: {error}" - ))); + return Err(response_with_phase( + FailurePhase::LaunchFailed, + &format!("Bubblewrap: failed to allocate PTY: {error}"), + )); } }, }; @@ -600,10 +617,10 @@ impl BubblewrapScriptRunner { Ok(process) => process, Err(error) => { stop_proxy_network(&mut proxy_network, logger); - return Err(ScriptResponse::error(&format!( - "Bubblewrap: failed to spawn bwrap: {}", - error - ))); + return Err(response_with_phase( + FailurePhase::LaunchFailed, + &format!("Bubblewrap: failed to spawn bwrap: {}", error), + )); } }; @@ -626,7 +643,7 @@ impl BubblewrapScriptRunner { let _ = child.kill(); let _ = child.wait(); stop_proxy_network(&mut proxy_network, logger); - return Err(ScriptResponse::error(&error)); + return Err(response_with_phase(FailurePhase::PostLaunchFailed, &error)); } } @@ -649,10 +666,13 @@ impl BubblewrapScriptRunner { let _ = child.wait(); stop_proxy_network(&mut proxy_network, logger); let error = out_result.err().or(err_result.err()); - return Err(ScriptResponse::error(&format!( - "Bubblewrap: failed to wrap stdio pipes: {}", - error.map_or_else(|| "unknown error".to_string(), |e| e.to_string()), - ))); + return Err(response_with_phase( + FailurePhase::PostLaunchFailed, + &format!( + "Bubblewrap: failed to wrap stdio pipes: {}", + error.map_or_else(|| "unknown error".to_string(), |e| e.to_string()), + ), + )); } }; let timeout = if request.script_timeout == 0 { @@ -2128,6 +2148,68 @@ mod tests { } } + /// A host without a usable bwrap is not a request the caller can rewrite, so + /// it must not be reported as a policy problem. + #[test] + fn an_unusable_bwrap_is_blamed_on_the_host() { + let request = base_request(); + let failures = [ + bwrap_version::BwrapUnavailable::NotFound, + bwrap_version::BwrapUnavailable::ProbeFailed { + status: Some(126), + detail: "permission denied".to_string(), + }, + bwrap_version::BwrapUnavailable::UnrecognizedVersion("junk".to_string()), + bwrap_version::BwrapUnavailable::TooOld(bwrap_version::BwrapVersion::new(0, 4, 1)), + ]; + + for failure in failures { + let detail = failure.to_string(); + let error = BubblewrapScriptRunner::new() + .validate_prepared_with_probe(&request, || Err(failure)) + .unwrap_err(); + + assert_eq!( + error.failure_phase, + FailurePhase::BackendUnavailable, + "input={detail}; output={error:?}" + ); + assert_eq!(error.exit_code, -1, "input={detail}; output={error:?}"); + } + } + + #[test] + fn a_policy_this_backend_will_not_run_is_blamed_on_the_request() { + let runner = BubblewrapScriptRunner::new(); + let empty_script = ExecutionRequest { + script_code: String::new(), + ..base_request() + }; + + for (what, request) in [ + ("empty script_code", empty_script), + ( + "proxy credentials", + request_with_a_credential_bearing_proxy(), + ), + ] { + let refusal = runner + .validate(&request) + .err() + .unwrap_or_else(|| panic!("input={what}; expected a refusal")); + + assert_eq!( + refusal.failure_phase, + FailurePhase::Rejected, + "input={what}; output={refusal:?}" + ); + assert_eq!( + refusal.exit_code, 1, + "input={what}; a refusal exits 1; output={refusal:?}" + ); + } + } + /// A denied symlink pointing at a **directory** is rewritten to its canonical /// target so the mask lands on the real directory (bwrap cannot mount a mask /// over a symlink whose parent is bound). The resolved directory is diff --git a/src/mxc-sdk/src/backends/hyperlight/common/mod.rs b/src/mxc-sdk/src/backends/hyperlight/common/mod.rs index 6e03c1874..c41cc2a57 100644 --- a/src/mxc-sdk/src/backends/hyperlight/common/mod.rs +++ b/src/mxc-sdk/src/backends/hyperlight/common/mod.rs @@ -89,9 +89,10 @@ //! ## Exit codes //! //! Guest exit code on clean completion (0 for normal exit, non-zero for -//! `sys.exit(N)` or unhandled exceptions), -1 on any runner error -//! (preflight, install, runtime, guest crash, timeout). The specific -//! failure mode is in `error_message`. +//! `sys.exit(N)` or unhandled exceptions). A refused request exits 1; a +//! host that cannot provide the backend, a failed install or boot, a guest +//! crash and a timeout all exit -1. The specific failure mode is in +//! `error_message`, and `failure_phase` says which side is at fault. use std::path::{Path, PathBuf}; use std::sync::mpsc::{self, RecvTimeoutError}; @@ -99,7 +100,9 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use crate::mxc_common::logger::Logger; -use crate::mxc_common::models::{ExecutionRequest, HyperlightRuntime, ScriptResponse}; +use crate::mxc_common::models::{ + ExecutionRequest, FailurePhase, HyperlightRuntime, ScriptResponse, +}; use crate::mxc_common::script_runner::ScriptRunner; use crate::mxc_common::validator::{ validate_common, validate_network_policy_support, NetworkPolicySupport, @@ -137,33 +140,60 @@ pub fn is_whp_available() -> bool { #[derive(Debug)] enum RunnerError { - /// Pre-spawn validation failures (missing image, unsupported policy). - Preflight(String), - /// Runtime construction, install, or execution failure. - Runtime(String), + /// The request asks for something the guest cannot do. + Policy(String), + /// This host cannot provide the backend: no usable guest image for the + /// requested runtime, and nothing to install one from. + Unavailable(String), + /// Host-side preparation of a run failed (mount staging, path resolution). + Setup(String), + /// Boot, install, or snapshot bring-up failed. + Launch(String), + /// The guest failed while running the call. + Exec(String), + /// The call outlived `scriptTimeout` and was cut short. + Timeout(String), } impl std::fmt::Display for RunnerError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - RunnerError::Preflight(msg) => write!(f, "hyperlight preflight error: {msg}"), - RunnerError::Runtime(msg) => write!(f, "hyperlight runtime error: {msg}"), + RunnerError::Policy(msg) => write!(f, "hyperlight policy error: {msg}"), + RunnerError::Unavailable(msg) => { + write!(f, "hyperlight is not available on this host: {msg}") + } + RunnerError::Setup(msg) => write!(f, "hyperlight setup error: {msg}"), + RunnerError::Launch(msg) | RunnerError::Exec(msg) | RunnerError::Timeout(msg) => { + write!(f, "hyperlight runtime error: {msg}") + } } } } impl RunnerError { + /// Which side a failure is attributed to: the request, the host, or the + /// run itself. + fn failure_phase(&self) -> FailurePhase { + match self { + RunnerError::Policy(_) => FailurePhase::Rejected, + RunnerError::Unavailable(_) => FailurePhase::BackendUnavailable, + RunnerError::Setup(_) | RunnerError::Launch(_) => FailurePhase::LaunchFailed, + RunnerError::Exec(_) => FailurePhase::PostLaunchFailed, + RunnerError::Timeout(_) => FailurePhase::Timeout, + } + } + fn to_response(&self) -> ScriptResponse { + let failure_phase = self.failure_phase(); ScriptResponse { - exit_code: ERROR_EXIT_CODE, + exit_code: failure_phase.failure_exit_code(), error_message: self.to_string(), + failure_phase, ..Default::default() } } } -const ERROR_EXIT_CODE: i32 = -1; - /// Env var override for the Hyperlight image home. Set this to force a /// specific location; otherwise the runner uses a standard OS-local /// data path (~/.local/share/mxc-hyperlight on Linux, %LOCALAPPDATA%\mxc-hyperlight on @@ -406,7 +436,7 @@ impl HyperlightScriptRunner { default.join(name) ), }; - Err(RunnerError::Preflight(format!( + Err(RunnerError::Unavailable(format!( "no hyperlight image found for the {name} runtime. searched ${HOME_ENV}, \ {default:?}, /{EXE_RELATIVE_HOME}/, /{CWD_RELATIVE_HOME}/, each under \ `{name}/`. {hint}" @@ -444,10 +474,10 @@ impl HyperlightScriptRunner { /// Reject policies the guest cannot enforce before any sandbox is booted. fn validate_policies(request: &ExecutionRequest) -> Result<(), RunnerError> { if request.policy.network_proxy.is_enabled() { - return Err(RunnerError::Preflight(ERR_PROXY_POLICY.to_string())); + return Err(RunnerError::Policy(ERR_PROXY_POLICY.to_string())); } if !request.working_directory.is_empty() { - return Err(RunnerError::Preflight(ERR_WORKDIR.to_string())); + return Err(RunnerError::Policy(ERR_WORKDIR.to_string())); } // Denied paths: block early if any appears in the allow lists. for denied in &request.policy.denied_paths { @@ -459,7 +489,7 @@ impl HyperlightScriptRunner { { match path_relationship(allowed, denied) { PathRelationship::Overlaps => { - return Err(RunnerError::Preflight(format!( + return Err(RunnerError::Policy(format!( "deniedPaths entry {denied:?} overlaps allow list path {allowed:?}; \ Hyperlight cannot safely expose overlapping allowed and denied \ host paths" @@ -467,7 +497,7 @@ impl HyperlightScriptRunner { } PathRelationship::Separate => {} PathRelationship::Indeterminate => { - return Err(RunnerError::Preflight(format!( + return Err(RunnerError::Policy(format!( "Hyperlight: cannot verify deniedPaths against allow lists because \ {allowed:?} or {denied:?} could not be resolved to a canonical \ location. With deniedPaths present, MXC fails closed rather than \ @@ -511,13 +541,13 @@ impl HyperlightScriptRunner { .map(|p| p.as_os_str().is_empty() || p.exists()) .unwrap_or(false); if !parent_ok { - return Err(RunnerError::Preflight(format!( + return Err(RunnerError::Policy(format!( "mount path {host:?} does not exist and its parent doesn't either; \ refusing to auto-create (fix the path or `mkdir -p` manually)" ))); } std::fs::create_dir_all(&host_path).map_err(|e| { - RunnerError::Preflight(format!("auto-create mount dir {host:?}: {e}")) + RunnerError::Setup(format!("auto-create mount dir {host:?}: {e}")) })?; } @@ -525,12 +555,12 @@ impl HyperlightScriptRunner { // so a relative path keeps meaning the same directory after a // cwd change. let host_path = std::fs::canonicalize(&host_path) - .map_err(|e| RunnerError::Preflight(format!("resolve mount path {host:?}: {e}")))?; + .map_err(|e| RunnerError::Setup(format!("resolve mount path {host:?}: {e}")))?; let basename = host_path .file_name() .and_then(|n| n.to_str()) .ok_or_else(|| { - RunnerError::Preflight(format!("mount path {host:?} has no filename component")) + RunnerError::Policy(format!("mount path {host:?} has no filename component")) })?; // The guest path travels to the kernel in its `vfs.fstab` // list, which these characters would break; the library @@ -539,14 +569,14 @@ impl HyperlightScriptRunner { .chars() .any(|c| c.is_whitespace() || matches!(c, ':' | '[' | ']')) { - return Err(RunnerError::Preflight(format!( + return Err(RunnerError::Policy(format!( "mount path {host:?}: the directory name may not contain whitespace, \ ':' or brackets (it names the guest mount point)" ))); } let guest_path = format!("{GUEST_MOUNT_ROOT}/{basename}"); if !seen_guest_paths.insert(guest_path.clone()) { - return Err(RunnerError::Preflight(format!( + return Err(RunnerError::Policy(format!( "two mount paths collide on guest path {guest_path:?}; \ rename one of the host directories" ))); @@ -616,7 +646,7 @@ impl HyperlightScriptRunner { let builder = SandboxBuilder::from_snapshot(rewind).mounts(mounts.iter().cloned()); let sandbox = builder .boot() - .map_err(|e| RunnerError::Runtime(format!("restore hyperlight snapshot: {e}")))?; + .map_err(|e| RunnerError::Launch(format!("restore hyperlight snapshot: {e}")))?; Ok(Guest { sandbox, fresh: true, @@ -724,11 +754,11 @@ impl HyperlightScriptRunner { // next call boots another from the rewind point. self.guest = None; let err = match failure { - RunError::TimedOut(timeout) => RunnerError::Runtime(format!( + RunError::TimedOut(timeout) => RunnerError::Timeout(format!( "execution timed out after {:.1}s", timeout.as_secs_f64() )), - RunError::Failed(msg) => RunnerError::Runtime(format!("run: {msg}")), + RunError::Failed(msg) => RunnerError::Exec(format!("run: {msg}")), }; logger.log_line(&err.to_string()); err.to_response() @@ -873,7 +903,7 @@ fn warm_snapshot( configure_surrogates(); let mut sandbox = rootfs_builder(home, runtime) .boot() - .map_err(|e| RunnerError::Runtime(format!("boot hyperlight rootfs: {e}")))?; + .map_err(|e| RunnerError::Launch(format!("boot hyperlight rootfs: {e}")))?; // Saved beside the snapshot directory, so a failed save leaves whatever // is there standing, then published by rename. let staged = home.join(format!(".{SNAPSHOT_DIR}.{}.part", std::process::id())); @@ -882,7 +912,7 @@ fn warm_snapshot( Ok(snapshot) => snapshot, Err(e) => { let _ = std::fs::remove_dir_all(&staged); - return Err(RunnerError::Runtime(format!( + return Err(RunnerError::Launch(format!( "save snapshot to {staged:?}: {e}" ))); } @@ -975,7 +1005,7 @@ fn load_persisted_snapshot( Err(e) => e, }; if !has_install_source(home, runtime) { - return Err(RunnerError::Preflight(no_install_source(runtime))); + return Err(RunnerError::Unavailable(no_install_source(runtime))); } logger.log_line(&match unloadable { hyperlight_unikraft::Error::SnapshotRelease { saved_by, .. } => format!( @@ -1845,7 +1875,8 @@ mod tests { }; let mut logger = Logger::new(Mode::Buffer); let resp = r.run(&request, &mut logger); - assert_eq!(resp.exit_code, ERROR_EXIT_CODE); + assert_eq!(resp.failure_phase, FailurePhase::Rejected); + assert_eq!(resp.exit_code, FailurePhase::Rejected.failure_exit_code()); assert_denied_overlap_message(&resp.error_message); } @@ -1949,7 +1980,8 @@ mod tests { }; let mut logger = Logger::new(Mode::Buffer); let resp = r.run(&request, &mut logger); - assert_eq!(resp.exit_code, ERROR_EXIT_CODE); + assert_eq!(resp.failure_phase, FailurePhase::Rejected); + assert_eq!(resp.exit_code, FailurePhase::Rejected.failure_exit_code()); assert_denied_overlap_message(&resp.error_message); } @@ -1970,7 +2002,8 @@ mod tests { }; let mut logger = Logger::new(Mode::Buffer); let resp = r.run(&request, &mut logger); - assert_eq!(resp.exit_code, ERROR_EXIT_CODE); + assert_eq!(resp.failure_phase, FailurePhase::Rejected); + assert_eq!(resp.exit_code, FailurePhase::Rejected.failure_exit_code()); assert_denied_overlap_message(&resp.error_message); } @@ -2167,7 +2200,43 @@ mod tests { }; let mut logger = Logger::new(Mode::Buffer); let resp = r.run(&request, &mut logger); - assert_eq!(resp.exit_code, ERROR_EXIT_CODE); + assert_eq!(resp.failure_phase, FailurePhase::Rejected); + assert_eq!(resp.exit_code, FailurePhase::Rejected.failure_exit_code()); assert!(resp.error_message.contains(ERR_WORKDIR)); } + + #[test] + fn a_host_without_an_image_blames_the_host() { + let resp = + RunnerError::Unavailable(no_install_source(HyperlightRuntime::Agent)).to_response(); + assert_eq!(resp.failure_phase, FailurePhase::BackendUnavailable); + assert_eq!(resp.exit_code, -1); + assert!(resp.error_message.contains("not available on this host")); + } + + #[test] + fn run_faults_keep_the_generic_backend_error_code() { + for (err, phase) in [ + ( + RunnerError::Setup("mount".to_string()), + FailurePhase::LaunchFailed, + ), + ( + RunnerError::Launch("boot".to_string()), + FailurePhase::LaunchFailed, + ), + ( + RunnerError::Exec("run".to_string()), + FailurePhase::PostLaunchFailed, + ), + ( + RunnerError::Timeout("deadline".to_string()), + FailurePhase::Timeout, + ), + ] { + let resp = err.to_response(); + assert_eq!(resp.failure_phase, phase, "{err}"); + assert_eq!(resp.exit_code, -1, "{err}"); + } + } } diff --git a/src/mxc-sdk/src/backends/isolation_session/common/error.rs b/src/mxc-sdk/src/backends/isolation_session/common/error.rs index ad6e3c9bf..173e4311a 100644 --- a/src/mxc-sdk/src/backends/isolation_session/common/error.rs +++ b/src/mxc-sdk/src/backends/isolation_session/common/error.rs @@ -251,7 +251,14 @@ impl std::fmt::Display for IsolationSessionError { impl From for ScriptResponse { fn from(err: IsolationSessionError) -> Self { - ScriptResponse::error(&err.to_string()) + let message = err.to_string(); + match err { + IsolationSessionError::Policy(_) => ScriptResponse::rejected(&message), + IsolationSessionError::ServiceUnavailable(_) => ScriptResponse::unavailable(&message), + IsolationSessionError::Lifecycle(_) | IsolationSessionError::Stale(_) => { + ScriptResponse::error(&message) + } + } } } @@ -498,6 +505,7 @@ pub(crate) mod test_support { #[cfg(test)] mod tests { use super::*; + use crate::mxc_common::models::FailurePhase; fn api_failure(code: Option) -> IsoApiFailure { IsoApiFailure::new( @@ -952,6 +960,23 @@ mod tests { assert!(response.error_message.contains("remediation")); } + /// The one-shot path has no structured envelope, so the phase on the + /// response is the only thing that tells the caller who is at fault. + #[test] + fn script_response_conversion_blames_the_same_side_as_the_wire_error() { + let refused: ScriptResponse = IsolationSessionError::Policy("no proxy".into()).into(); + assert_eq!(refused.failure_phase, FailurePhase::Rejected); + assert_eq!(refused.exit_code, 1); + + let unavailable: ScriptResponse = activation_error(REGDB_E_CLASSNOTREG_HRESULT, "x").into(); + assert_eq!(unavailable.failure_phase, FailurePhase::BackendUnavailable); + assert_eq!(unavailable.exit_code, -1); + + let runtime: ScriptResponse = lifecycle_err("create stdout relay: out of memory").into(); + assert_eq!(runtime.failure_phase, FailurePhase::None); + assert_eq!(runtime.exit_code, -1); + } + // ── operation values ───────────────────────────────────────────────── /// `operation` must stay interface-qualified and free of call diff --git a/src/mxc-sdk/src/backends/isolation_session/common/one_shot.rs b/src/mxc-sdk/src/backends/isolation_session/common/one_shot.rs index 68161e291..cddb08086 100644 --- a/src/mxc-sdk/src/backends/isolation_session/common/one_shot.rs +++ b/src/mxc-sdk/src/backends/isolation_session/common/one_shot.rs @@ -9,7 +9,7 @@ use std::fmt::Write; use std::io::IsTerminal; use crate::mxc_common::logger::Logger; -use crate::mxc_common::models::{ExecutionRequest, ScriptResponse}; +use crate::mxc_common::models::{ExecutionRequest, FailurePhase, ScriptResponse}; use crate::mxc_common::script_runner::ScriptRunner; use crate::mxc_common::validator::{validate_network_policy_support, NetworkPolicySupport}; @@ -34,13 +34,13 @@ use super::IsolationSessionRunner; /// anything applied — so there is nothing to retain. fn reject_unsupported_lifecycle(request: &ExecutionRequest) -> Result<(), ScriptResponse> { if !request.lifecycle.destroy_on_exit { - return Err(ScriptResponse::error( + return Err(ScriptResponse::rejected( "lifecycle.destroyOnExit=false is not supported by the isolation session backend; \ the session is always stopped and the agent user removed", )); } if request.lifecycle.preserve_policy { - return Err(ScriptResponse::error( + return Err(ScriptResponse::rejected( "lifecycle.preservePolicy=true is not supported by the isolation session backend; \ it installs no persistent filesystem or network enforcement, so there is none \ to preserve", @@ -163,11 +163,22 @@ impl ScriptRunner for IsolationSessionRunner { standard_out: String::new(), standard_err: String::new(), error_message: String::new(), + failure_phase: exited_phase(exit_code), ..Default::default() } } } +/// The phase a completed run reports, so a workload that failed on its own is +/// told apart from the sandbox failing around it. +fn exited_phase(exit_code: i32) -> FailurePhase { + if exit_code == 0 { + FailurePhase::None + } else { + FailurePhase::ProcessExited + } +} + #[cfg(test)] mod tests { use super::*; @@ -175,6 +186,13 @@ mod tests { ContainerPolicy, LifecycleConfig, NetworkAction, NetworkEgressPolicy, NetworkIngressPolicy, }; + #[test] + fn a_nonzero_workload_exit_is_reported_as_a_process_exit() { + assert_eq!(exited_phase(0), FailurePhase::None); + assert_eq!(exited_phase(3), FailurePhase::ProcessExited); + assert_eq!(exited_phase(-1), FailurePhase::ProcessExited); + } + #[test] fn validate_runner_one_shot_rejects_default_network() { // No user, but the default (absent → `Block`) network is not the @@ -250,6 +268,29 @@ mod tests { ); } + #[test] + fn validate_runner_one_shot_lifecycle_refusals_blame_the_request() { + let runner = IsolationSessionRunner::new(); + for lifecycle in [ + LifecycleConfig { + destroy_on_exit: false, + preserve_policy: false, + }, + LifecycleConfig { + destroy_on_exit: true, + preserve_policy: true, + }, + ] { + let req = ExecutionRequest { + lifecycle, + ..canonical_request() + }; + let resp = runner.validate_runner(&req).unwrap_err(); + assert_eq!(resp.failure_phase, FailurePhase::Rejected); + assert_eq!(resp.exit_code, 1); + } + } + #[test] fn validate_runner_one_shot_accepts_default_lifecycle() { // `destroyOnExit: true` (the default) is exactly what the backend does, diff --git a/src/mxc-sdk/src/backends/isolation_session/common/sandbox.rs b/src/mxc-sdk/src/backends/isolation_session/common/sandbox.rs index 31166252a..9c2451e0b 100644 --- a/src/mxc-sdk/src/backends/isolation_session/common/sandbox.rs +++ b/src/mxc-sdk/src/backends/isolation_session/common/sandbox.rs @@ -21,7 +21,7 @@ use std::thread::JoinHandle; use crate::mxc_common::logger::Logger; use crate::mxc_common::models::{ExecutionRequest, FailurePhase, ScriptResponse}; -use crate::mxc_common::mxc_error::MxcError; +use crate::mxc_common::mxc_error::{MxcError, MxcErrorCode}; use crate::mxc_common::process_util::{ InterruptiblePipeReader, OwnedHandle as ProcessOwnedHandle, PipeReadCanceller, }; @@ -75,13 +75,27 @@ pub fn spawn_one_shot( /// workload exits, and whose relay scope borrows the process — into a /// non-blocking start with detached relays. fn inherit_not_served() -> ScriptResponse { - ScriptResponse { - failure_phase: FailurePhase::Rejected, - ..ScriptResponse::error( - "the isolation session backend does not yet support inherited stdio; it serves piped \ - stdio, which is what an in-process caller receives. Run it through wxc-exec to have \ - the workload relayed onto this process's own stdio.", - ) + ScriptResponse::rejected( + "the isolation session backend does not yet support inherited stdio; it serves piped \ + stdio, which is what an in-process caller receives. Run it through wxc-exec to have \ + the workload relayed onto this process's own stdio.", + ) +} + +/// Flattens a launch failure onto a [`ScriptResponse`], keeping the side the +/// typed error blames. +/// +/// A launch reaches the OS API, so it can fail because that API is not there at +/// all. Reporting that as a launch failure would point the caller at a retry +/// that cannot help. +fn launch_failure_response(error: &MxcError) -> ScriptResponse { + match error.code { + MxcErrorCode::BackendUnavailable => ScriptResponse::unavailable(&error.message), + MxcErrorCode::PolicyValidation => ScriptResponse::rejected(&error.message), + _ => ScriptResponse { + failure_phase: FailurePhase::LaunchFailed, + ..ScriptResponse::error(&error.message) + }, } } @@ -89,13 +103,7 @@ impl SandboxBackend for IsolationSessionRunner { fn validate(&self, request: &ExecutionRequest) -> Result<(), ScriptResponse> { // One-shot runs the whole lifecycle, so provision-phase rules apply to // the whole call — the same check the run-to-completion path makes. - // - // Everything it refuses is a caller-fixable request problem, and the - // phase is what the dispatcher reads back to classify it. - ScriptRunner::validate_runner(self, request).map_err(|resp| ScriptResponse { - failure_phase: FailurePhase::Rejected, - ..resp - }) + ScriptRunner::validate_runner(self, request) } /// Provision, start, and hand back the exec's live pipes. @@ -114,19 +122,13 @@ impl SandboxBackend for IsolationSessionRunner { StdioMode::Pty(size) => { spawn_one_shot_pty(request, logger, size).map_err(|error| match error { OneShotSpawnFailure::Refused(response) => response, - OneShotSpawnFailure::Launch(error) => ScriptResponse { - failure_phase: FailurePhase::LaunchFailed, - ..ScriptResponse::error(&error.message) - }, + OneShotSpawnFailure::Launch(error) => launch_failure_response(&error), }) } // Delegated so both entry points share one validation prologue. StdioMode::Pipes => spawn_one_shot(request, logger).map_err(|e| match e { OneShotSpawnFailure::Refused(resp) => resp, - OneShotSpawnFailure::Launch(err) => ScriptResponse { - failure_phase: FailurePhase::LaunchFailed, - ..ScriptResponse::error(&err.message) - }, + OneShotSpawnFailure::Launch(err) => launch_failure_response(&err), }), } } @@ -801,7 +803,6 @@ impl Drop for IsolationPtyProcess { #[cfg(test)] mod tests { use super::*; - use crate::mxc_common::mxc_error::MxcErrorCode; /// A launch failure that also failed to clean up keeps its own /// classification, so a caller still branches on the real cause. @@ -837,4 +838,29 @@ mod tests { let after = with_cleanup_failures(launch, &[]).to_string(); assert_eq!(before, after); } + + /// Flattening onto a `ScriptResponse` must not turn an absent API into a + /// launch failure the caller would retry. + #[test] + fn a_launch_response_keeps_the_side_the_typed_error_blames() { + let unavailable = + launch_failure_response(&MxcError::backend_unavailable("the API is not registered")); + assert_eq!(unavailable.failure_phase, FailurePhase::BackendUnavailable); + assert_eq!(unavailable.exit_code, -1); + + let refused = launch_failure_response(&MxcError::policy_validation("no proxy")); + assert_eq!(refused.failure_phase, FailurePhase::Rejected); + assert_eq!(refused.exit_code, 1); + + let launch = launch_failure_response(&MxcError::backend_error("StartSessionAsync failed")); + assert_eq!(launch.failure_phase, FailurePhase::LaunchFailed); + assert_eq!(launch.exit_code, -1); + } + + #[test] + fn inherited_stdio_is_refused_as_a_request_problem() { + let refused = inherit_not_served(); + assert_eq!(refused.failure_phase, FailurePhase::Rejected); + assert_eq!(refused.exit_code, 1); + } } diff --git a/src/mxc-sdk/src/backends/lxc/common/availability.rs b/src/mxc-sdk/src/backends/lxc/common/availability.rs index fbca924c7..3aee37b5a 100644 --- a/src/mxc-sdk/src/backends/lxc/common/availability.rs +++ b/src/mxc-sdk/src/backends/lxc/common/availability.rs @@ -24,6 +24,15 @@ pub fn is_lxc_available() -> bool { *AVAILABLE.get_or_init(|| available_from(probe_lxc_ls())) } +/// [`is_lxc_available`] without the cached discovery answer. +/// +/// Execution must not inherit an advisory verdict: a host that gained the LXC +/// tools after an earlier probe can run a sandbox now, and a cached `false` +/// would refuse it for the life of the process. +pub fn is_lxc_available_uncached() -> bool { + available_from(probe_lxc_ls()) +} + fn probe_lxc_ls() -> LxcLsOutcome { let child = Command::new("lxc-ls") .arg("--version") diff --git a/src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs b/src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs index 9abf2c93b..afea21dfe 100644 --- a/src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs +++ b/src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs @@ -10,7 +10,7 @@ use std::time::{Duration, Instant}; use crate::mxc_common::logger::Logger; use crate::mxc_common::models::{ - ContainerPolicy, ExecutionRequest, LifecycleConfig, LxcConfig, ScriptResponse, + ContainerPolicy, ExecutionRequest, FailurePhase, LifecycleConfig, LxcConfig, ScriptResponse, }; use crate::mxc_common::sandbox_process::{SandboxBackend, SandboxProcess, StdioMode}; use crate::mxc_common::script_runner::ScriptRunner; @@ -39,6 +39,18 @@ use crate::lxc_common::signal_cleanup; const HOSTS_PIN_MARKER: &str = "#mxc-proxy-pin"; +/// A runtime fault, tagged with the phase of the run it broke in. +/// +/// A refused request and an unusable host have their own constructors on +/// [`ScriptResponse`]; this covers the faults that stay `backend_error` and +/// only say *where* the run came apart. +fn response_with_phase(phase: FailurePhase, message: &str) -> ScriptResponse { + ScriptResponse { + failure_phase: phase, + ..ScriptResponse::error(message) + } +} + /// `PATH` for the contained child, from schema 0.9. /// /// The script runs with `lxc-attach` in clear-env mode, which supplies a small @@ -201,13 +213,16 @@ impl LxcScriptRunner { return self.claim_generated_name(); } ContainerNameClaim::acquire(&self.container_id).ok_or_else(|| { - ScriptResponse::error(&format!( - "LXC: container '{}' is already in use by a run in this process. LXC reads a \ - run's network section only when the container starts, so a second run would \ - stop the first one's workload to restart it under its own policy. Let the \ - first run finish, or give this one its own containerId.", - self.container_id - )) + response_with_phase( + FailurePhase::LaunchFailed, + &format!( + "LXC: container '{}' is already in use by a run in this process. LXC reads a \ + run's network section only when the container starts, so a second run would \ + stop the first one's workload to restart it under its own policy. Let the \ + first run finish, or give this one its own containerId.", + self.container_id + ), + ) }) } @@ -219,7 +234,8 @@ impl LxcScriptRunner { return Ok(claim); } } - Err(ScriptResponse::error( + Err(response_with_phase( + FailurePhase::LaunchFailed, "LXC: could not find a free container name for this run; every generated name was \ already in use by another run in this process. Retry, or set containerId to a name \ of your own.", @@ -390,13 +406,16 @@ impl LxcScriptRunner { let release = self.release_kind(container_created); Self::report_release_failure(release, release_container(release), logger); - Some(ScriptResponse::error(&format!( - "Container did not receive an IPv4 address within {:.0}s; \ - check that lxc-net/dnsmasq is running and able to assign one. \ - A bridge offering only IPv6 leaves the container unable to reach \ - the IPv4 destinations its policy names.", - timeout.as_secs_f64() - ))) + Some(response_with_phase( + FailurePhase::PostLaunchFailed, + &format!( + "Container did not receive an IPv4 address within {:.0}s; \ + check that lxc-net/dnsmasq is running and able to assign one. \ + A bridge offering only IPv6 leaves the container unable to reach \ + the IPv4 destinations its policy names.", + timeout.as_secs_f64() + ), + )) } fn enforce_netns_discovery( @@ -417,7 +436,8 @@ impl LxcScriptRunner { let release = self.release_kind(container_created); Self::report_release_failure(release, release_container(release), logger); - Err(ScriptResponse::error( + Err(response_with_phase( + FailurePhase::PostLaunchFailed, "Failed to discover the container init PID; cannot enter the container \ network namespace to enforce the requested firewall. Aborting rather \ than running with enforcement silently disabled.", @@ -445,14 +465,14 @@ impl LxcScriptRunner { &normalized } Ok(None) => request, - Err(msg) => return Err(ScriptResponse::error(&msg)), + Err(msg) => return Err(ScriptResponse::rejected(&msg)), }; if let Err(msg) = crate::mxc_common::filesystem_access::check_delegation(&request.policy) { - return Err(ScriptResponse::error(&msg)); + return Err(ScriptResponse::rejected(&msg)); } if self.config.distribution.is_empty() || self.config.release.is_empty() { - return Err(ScriptResponse::error( + return Err(ScriptResponse::rejected( "LXC distribution and release are required \ (e.g., \"distribution\": \"alpine\", \"release\": \"3.23\")", )); @@ -464,7 +484,13 @@ impl LxcScriptRunner { // The policy lowers to the same rules with or without a container, so // one that cannot be programmed is refused before a container exists. if let Err(msg) = NetworkIptablesManager::validate_egress_lowering(&request.policy) { - return Err(ScriptResponse::error(&msg)); + return Err(ScriptResponse::rejected(&msg)); + } + + // Probed per run rather than through the cached discovery answer: a + // host that gained the LXC tooling after an earlier probe is usable now. + if !crate::lxc_common::availability::is_lxc_available_uncached() { + return Err(ScriptResponse::unavailable(LXC_TOOLING_UNAVAILABLE)); } if self.destroy_on_exit { @@ -493,10 +519,10 @@ impl LxcScriptRunner { if !container.is_defined() { let _ = writeln!(logger, "Creating LXC container..."); if let Err(e) = container.create(&self.config.distribution, &self.config.release) { - return Err(ScriptResponse::error(&format!( - "Failed to create container: {}", - e - ))); + return Err(response_with_phase( + FailurePhase::LaunchFailed, + &format!("Failed to create container: {}", e), + )); } let _ = writeln!(logger, "Container created successfully."); container_created = true; @@ -510,10 +536,10 @@ impl LxcScriptRunner { if self.destroy_on_exit || container_created { let _ = container.destroy(); } - return Err(ScriptResponse::error(&format!( - "Failed to configure filesystem: {}", - e - ))); + return Err(response_with_phase( + FailurePhase::LaunchFailed, + &format!("Failed to configure filesystem: {}", e), + )); } // LXC reads the network section only at start. A container an earlier @@ -527,11 +553,14 @@ impl LxcScriptRunner { if self.destroy_on_exit || container_created { let _ = container.destroy(); } - return Err(ScriptResponse::error(&format!( - "Failed to stop a container left running by an earlier run: {}. \ - Its network policy is the earlier run's, so the script was not run.", - e - ))); + return Err(response_with_phase( + FailurePhase::LaunchFailed, + &format!( + "Failed to stop a container left running by an earlier run: {}. \ + Its network policy is the earlier run's, so the script was not run.", + e + ), + )); } } @@ -552,10 +581,10 @@ impl LxcScriptRunner { if self.destroy_on_exit || container_created { let _ = container.destroy(); } - return Err(ScriptResponse::error(&format!( - "Failed to start container: {}", - e - ))); + return Err(response_with_phase( + FailurePhase::LaunchFailed, + &format!("Failed to start container: {}", e), + )); } let _ = writeln!(logger, "Container started successfully."); @@ -612,7 +641,7 @@ impl LxcScriptRunner { Ok(managers) => managers, Err(e) => { self.release_after_failure(&container, container_created, logger); - return Err(ScriptResponse::error(&e)); + return Err(response_with_phase(FailurePhase::PostLaunchFailed, &e)); } }; @@ -638,12 +667,15 @@ impl LxcScriptRunner { }); if let Err(reason) = stale_pin_error { self.release_after_failure(&container, container_created, logger); - return Err(ScriptResponse::error(&format!( - "Failed to clear a stale network proxy pin from the container's \ - /etc/hosts: {}. The script was not run, because it could have resolved \ - the pinned hostname to an address this policy did not authorize.", - reason - ))); + return Err(response_with_phase( + FailurePhase::PostLaunchFailed, + &format!( + "Failed to clear a stale network proxy pin from the container's \ + /etc/hosts: {}. The script was not run, because it could have resolved \ + the pinned hostname to an address this policy did not authorize.", + reason + ), + )); } // `script_timeout == 0` means "no timeout" per the SDK contract. @@ -958,6 +990,11 @@ pub const LXC_INHERIT_STDIO_UNSUPPORTED: &str = pub const LXC_STREAMING_LINUX_ONLY: &str = "LXC: sandboxes can only be launched on Linux."; +pub const LXC_TOOLING_UNAVAILABLE: &str = + "LXC: the LXC runtime is not available on this host — `lxc-ls --version` did not run. \ + Install the LXC tools (e.g. apt install lxc) and make sure they are on PATH, or select \ + a backend this host can provide."; + fn unsupported_network_proxy(policy: &ContainerPolicy) -> Option<&'static str> { if policy.network_proxy.is_enabled() { Some("network.proxy") @@ -976,10 +1013,10 @@ fn lxc_network_policy_support() -> NetworkPolicySupport { impl ScriptRunner for LxcScriptRunner { fn validate_runner(&self, request: &ExecutionRequest) -> Result<(), ScriptResponse> { if request.policy.runtime_network_proxy_specified { - return Err(ScriptResponse::error(LXC_RUNTIME_PROXY_UNSUPPORTED)); + return Err(ScriptResponse::rejected(LXC_RUNTIME_PROXY_UNSUPPORTED)); } if let Some(field) = unsupported_network_proxy(&request.policy) { - return Err(ScriptResponse::error(&format!( + return Err(ScriptResponse::rejected(&format!( "LXC: {field} is not supported and cannot be enforced; \ remove the proxy request." ))); @@ -1038,7 +1075,7 @@ impl LxcScriptRunner { ) -> Result, ScriptResponse> { match stdio { StdioMode::Inherit => { - return Err(ScriptResponse::error(LXC_INHERIT_STDIO_UNSUPPORTED)); + return Err(ScriptResponse::rejected(LXC_INHERIT_STDIO_UNSUPPORTED)); } StdioMode::Pty(_) | StdioMode::Pipes => {} } @@ -1096,10 +1133,10 @@ impl LxcScriptRunner { Ok(launched) => launched, Err(e) => { let cleanup = self.abandon_prepared(&mut prepared, logger); - return Err(ScriptResponse::error(&launch_failure_message( - &format!("Execution failed: {}", e), - &cleanup, - ))); + return Err(response_with_phase( + FailurePhase::LaunchFailed, + &launch_failure_message(&format!("Execution failed: {}", e), &cleanup), + )); } }; @@ -1126,13 +1163,16 @@ impl LxcScriptRunner { let cleanup = self.abandon_prepared(&mut prepared, logger); let error = out_result.err().or(err_result.err()); - return Err(ScriptResponse::error(&launch_failure_message( - &format!( - "Failed to wrap the lxc-attach stdio pipes: {}", - error.map_or_else(|| "unknown error".to_string(), |e| e.to_string()) + return Err(response_with_phase( + FailurePhase::PostLaunchFailed, + &launch_failure_message( + &format!( + "Failed to wrap the lxc-attach stdio pipes: {}", + error.map_or_else(|| "unknown error".to_string(), |e| e.to_string()) + ), + &cleanup, ), - &cleanup, - ))); + )); } }; @@ -1162,7 +1202,7 @@ impl LxcScriptRunner { _logger: &mut Logger, _stdio: StdioMode, ) -> Result, ScriptResponse> { - Err(ScriptResponse::error(LXC_STREAMING_LINUX_ONLY)) + Err(ScriptResponse::unavailable(LXC_STREAMING_LINUX_ONLY)) } } @@ -3104,6 +3144,136 @@ mod tests { ); } + /// A caller can only act on a failure it can attribute: a refused policy is + /// worth rewriting, a host that cannot run LXC at all is not. + #[test] + fn a_policy_this_backend_cannot_enforce_is_blamed_on_the_request() { + let runtime_proxy = ExecutionRequest { + script_code: "echo hello".to_string(), + policy: ContainerPolicy { + runtime_network_proxy_specified: true, + ..Default::default() + }, + ..Default::default() + }; + + for (what, request) in [ + ("runtimeConfig.networkProxy", runtime_proxy), + ( + "network.proxy", + request_with_proxy_url("http://proxy.example.com:8080"), + ), + ( + "unlowerable network.egress", + request_with_unlowerable_egress(), + ), + ] { + let refusal = runner_for_guard_tests("refusal-phase") + .prepare(&request, &mut Logger::new(Mode::Buffer)) + .err() + .unwrap_or_else(|| panic!("input={what}; expected a refusal")); + + assert_eq!( + refusal.failure_phase, + FailurePhase::Rejected, + "input={what}; expected a refusal of the request; output={refusal:?}" + ); + assert_eq!( + refusal.exit_code, 1, + "input={what}; a refusal exits 1; output={refusal:?}" + ); + } + } + + #[test] + fn an_image_the_config_never_named_is_blamed_on_the_request() { + let refusal = validating_runner() + .prepare(&ExecutionRequest::default(), &mut Logger::new(Mode::Buffer)) + .err() + .expect("an empty distribution and release cannot name an image to create"); + + assert_eq!(refusal.failure_phase, FailurePhase::Rejected); + assert_eq!(refusal.exit_code, 1); + } + + #[test] + fn inherited_stdio_is_refused_as_a_request_fault() { + let refusal = validating_runner() + .spawn( + &streamable_request(), + &mut Logger::new(Mode::Buffer), + StdioMode::Inherit, + ) + .err() + .expect("the library path has no pty to hand a workload"); + + assert_eq!( + refusal.failure_phase, + FailurePhase::Rejected, + "pipes and a caller-owned PTY are both reachable, so only the request has to change" + ); + } + + /// A container another live sandbox holds is contention, not a refusal: the + /// same request succeeds once the first run finishes. + #[test] + fn a_container_another_run_holds_is_a_launch_fault() { + let runner = runner_named("mxc-claim-phase-test"); + let _held = runner + .claim_container_name() + .expect("the first sandbox takes a container no other one is using"); + + let refusal = runner + .claim_container_name() + .expect_err("a second sandbox on a live container must be refused"); + + assert_eq!(refusal.failure_phase, FailurePhase::LaunchFailed); + assert_eq!(refusal.exit_code, -1); + } + + #[test] + fn a_container_that_never_got_an_address_is_a_post_launch_fault() { + let runner = runner_for_network_readiness_tests(false); + let response = fail_network_readiness(&runner, false).response; + + assert_eq!( + response.failure_phase, + FailurePhase::PostLaunchFailed, + "the container started; it was the sandbox network that never came up" + ); + } + + #[test] + fn an_undiscoverable_netns_is_a_post_launch_fault() { + let refusal = + runner_for_network_readiness_tests(false) + .enforce_netns_discovery(None, true, false, &mut Logger::new(Mode::Buffer), |_| { + Ok(()) + }) + .expect_err("a firewall that cannot be installed must not run unenforced"); + + assert_eq!(refusal.failure_phase, FailurePhase::PostLaunchFailed); + assert_eq!(refusal.exit_code, -1); + } + + /// Only reachable off Linux, where the backend does not exist at all. + #[cfg(not(target_os = "linux"))] + #[test] + fn a_host_that_cannot_run_lxc_is_blamed_instead_of_the_request() { + let refusal = validating_runner() + .launch_streaming( + &streamable_request(), + &mut Logger::new(Mode::Buffer), + StdioMode::Pipes, + ) + .err() + .expect("LXC sandboxes only launch on Linux"); + + assert_eq!(refusal.failure_phase, FailurePhase::BackendUnavailable); + assert_eq!(refusal.exit_code, -1); + assert_eq!(refusal.error_message, LXC_STREAMING_LINUX_ONLY); + } + #[test] fn both_runner_personalities_declare_the_same_network_support() { let runner = validating_runner(); diff --git a/src/mxc-sdk/src/backends/nanvix/runner/mod.rs b/src/mxc-sdk/src/backends/nanvix/runner/mod.rs index aa1dc3bfc..bf9b40a2b 100644 --- a/src/mxc-sdk/src/backends/nanvix/runner/mod.rs +++ b/src/mxc-sdk/src/backends/nanvix/runner/mod.rs @@ -56,7 +56,7 @@ use std::thread::JoinHandle; use std::time::{Duration, Instant}; use crate::mxc_common::logger::Logger; -use crate::mxc_common::models::{ExecutionRequest, NetworkAction, ScriptResponse}; +use crate::mxc_common::models::{ExecutionRequest, FailurePhase, NetworkAction, ScriptResponse}; use crate::mxc_common::script_runner::ScriptRunner; use crate::mxc_common::validator::{validate_network_policy_support, NetworkPolicySupport}; @@ -132,8 +132,13 @@ fn exit_code_from_status(status: &std::process::ExitStatus) -> i32 { /// Classifies NanVix runner errors for structured error handling. #[derive(Debug)] enum NanVixError { - /// Pre-spawn validation failures (missing artifacts, invalid config, unsupported policy). - Preflight(String), + /// The request asks for something the NanVix guest will not enforce. + Policy(String), + /// A NanVix runtime artifact the host must supply is missing. + Unavailable(String), + /// Host-side preparation failed before the micro-VM was spawned + /// (snapshot generation, staging, locating the installation). + Setup(String), /// OS/platform failures while spawning/managing the NanVix process (WHP/spawn/handles). Platform(String), /// Stdin broken pipe, VM crash. @@ -148,7 +153,11 @@ enum NanVixError { impl std::fmt::Display for NanVixError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - NanVixError::Preflight(msg) => write!(f, "NanVix preflight error: {}", msg), + NanVixError::Policy(msg) => write!(f, "NanVix policy error: {}", msg), + NanVixError::Unavailable(msg) => { + write!(f, "NanVix is not available on this host: {}", msg) + } + NanVixError::Setup(msg) => write!(f, "NanVix setup error: {}", msg), NanVixError::Platform(msg) => write!(f, "NanVix platform error: {}", msg), NanVixError::Runtime(msg) => write!(f, "NanVix runtime error: {}", msg), NanVixError::Timeout { @@ -165,10 +174,24 @@ impl std::fmt::Display for NanVixError { } impl NanVixError { + /// Which side a failure is attributed to: the request, the host, or the + /// run itself. + fn failure_phase(&self) -> FailurePhase { + match self { + NanVixError::Policy(_) => FailurePhase::Rejected, + NanVixError::Unavailable(_) => FailurePhase::BackendUnavailable, + NanVixError::Setup(_) | NanVixError::Platform(_) => FailurePhase::LaunchFailed, + NanVixError::Runtime(_) => FailurePhase::PostLaunchFailed, + NanVixError::Timeout { .. } => FailurePhase::Timeout, + } + } + fn to_response(&self) -> ScriptResponse { + let failure_phase = self.failure_phase(); ScriptResponse { - exit_code: ERROR_EXIT_CODE, + exit_code: failure_phase.failure_exit_code(), error_message: self.to_string(), + failure_phase, ..Default::default() } } @@ -180,11 +203,11 @@ impl NanVixError { /// `process_util` is gated to `target_os = "windows"`. fn exe_dir() -> Result { std::env::current_exe() - .map_err(|e| NanVixError::Preflight(format!("cannot determine exe path: {}", e))) + .map_err(|e| NanVixError::Setup(format!("cannot determine exe path: {}", e))) .and_then(|exe| { exe.parent() .map(|p| p.to_path_buf()) - .ok_or_else(|| NanVixError::Preflight("exe has no parent directory".to_string())) + .ok_or_else(|| NanVixError::Setup("exe has no parent directory".to_string())) }) } @@ -321,7 +344,7 @@ impl NanVixScriptRunner { let nanvixd = dir.join(NANVIXD_BINARY); if !nanvixd.exists() { - return Err(NanVixError::Preflight(format!( + return Err(NanVixError::Unavailable(format!( "{} not found in {:?}", NANVIXD_BINARY, dir ))); @@ -329,7 +352,7 @@ impl NanVixScriptRunner { let ramfs = dir.join(RAMFS_IMAGE); if !ramfs.exists() { - return Err(NanVixError::Preflight(format!( + return Err(NanVixError::Unavailable(format!( "{} not found in {:?}", RAMFS_IMAGE, dir ))); @@ -337,7 +360,7 @@ impl NanVixScriptRunner { let initrd = dir.join(INITRD_BINARY); if !initrd.exists() { - return Err(NanVixError::Preflight(format!( + return Err(NanVixError::Unavailable(format!( "{} not found in {:?}", INITRD_BINARY, dir ))); @@ -350,7 +373,7 @@ impl NanVixScriptRunner { for name in crate::nanvix_common::BIN_SUBDIR_FILES { let path = bin_subdir.join(name); if !path.exists() { - return Err(NanVixError::Preflight(format!( + return Err(NanVixError::Unavailable(format!( "{}/{} not found in {:?}", BIN_DIR, name, dir ))); @@ -405,7 +428,7 @@ impl NanVixScriptRunner { let p = PathBuf::from(val); if !p.as_os_str().is_empty() { std::fs::create_dir_all(&p).map_err(|e| { - NanVixError::Preflight(format!( + NanVixError::Setup(format!( "cannot create ${} directory {:?}: {}", NANVIX_HOME_ENV, p, e )) @@ -457,9 +480,8 @@ impl NanVixScriptRunner { ramfs: &Path, initrd: &Path, ) -> Result<(), NanVixError> { - std::fs::create_dir_all(snapshot_home).map_err(|e| { - NanVixError::Preflight(format!("failed to create snapshot home: {}", e)) - })?; + std::fs::create_dir_all(snapshot_home) + .map_err(|e| NanVixError::Setup(format!("failed to create snapshot home: {}", e)))?; eprintln!("nanvix: no snapshot found — generating via cold boot (one-time cost)..."); @@ -471,7 +493,7 @@ impl NanVixScriptRunner { ramfs, initrd, ) - .map_err(NanVixError::Preflight)?; + .map_err(NanVixError::Setup)?; eprintln!( "nanvix: snapshot generated in {:.0?} — subsequent runs will use warm start", @@ -501,7 +523,7 @@ impl NanVixScriptRunner { .as_ref() .is_some_and(|egress| !egress.allow.is_empty() || !egress.deny.is_empty()) { - return Err(NanVixError::Preflight(ERR_DIRECTIONAL_FILTERS.to_string())); + return Err(NanVixError::Policy(ERR_DIRECTIONAL_FILTERS.to_string())); } let egress = policy .network_egress @@ -519,7 +541,7 @@ impl NanVixScriptRunner { .map(|ingress| ingress.host_loopback) .unwrap_or(NetworkAction::Deny); if egress != ingress || ingress != host_loopback { - return Err(NanVixError::Preflight(ERR_DIRECTIONAL_NETWORK.to_string())); + return Err(NanVixError::Policy(ERR_DIRECTIONAL_NETWORK.to_string())); } Ok(egress == NetworkAction::Allow) } @@ -527,13 +549,13 @@ impl NanVixScriptRunner { fn validate_policies(request: &ExecutionRequest) -> Result<(), NanVixError> { // denied_paths is explicitly rejected — microvm has no host visibility. if !request.policy.denied_paths.is_empty() { - return Err(NanVixError::Preflight(ERR_DENIED_PATHS.to_string())); + return Err(NanVixError::Policy(ERR_DENIED_PATHS.to_string())); } if request.policy.network_proxy.is_enabled() { - return Err(NanVixError::Preflight(ERR_PROXY_POLICY.to_string())); + return Err(NanVixError::Policy(ERR_PROXY_POLICY.to_string())); } if !request.working_directory.is_empty() { - return Err(NanVixError::Preflight(ERR_WORKDIR.to_string())); + return Err(NanVixError::Policy(ERR_WORKDIR.to_string())); } Self::resolve_networking_mode(request)?; @@ -806,8 +828,9 @@ impl NanVixScriptRunner { } /// Returns `true` when filesystem copyback should run. /// Copyback runs on any normal process exit (including non-zero exit codes). - /// It is skipped for preflight, spawn, runtime, and timeout errors, and for - /// OS crashes (negative exit codes from NTSTATUS values). + /// It is skipped for every runner error (policy, availability, setup, spawn, + /// runtime, timeout) and for OS crashes (negative exit codes from NTSTATUS + /// values). fn should_copy_back(response: &ScriptResponse) -> bool { response.error_message.is_empty() && response.exit_code >= 0 } @@ -853,7 +876,7 @@ impl ScriptRunner for NanVixScriptRunner { ) { Ok(s) => s, Err(e) => { - let err = NanVixError::Preflight(e.to_string()); + let err = NanVixError::Setup(e.to_string()); let _ = writeln!(logger, "{}", err); return err.to_response(); } @@ -1184,7 +1207,8 @@ mod tests { }; let mut logger = Logger::new(Mode::Buffer); let resp = runner.run(&request, &mut logger); - assert_eq!(resp.exit_code, ERROR_EXIT_CODE); + assert_eq!(resp.failure_phase, FailurePhase::Rejected); + assert_eq!(resp.exit_code, FailurePhase::Rejected.failure_exit_code()); assert!(resp.error_message.contains(ERR_WORKDIR)); } @@ -1225,7 +1249,8 @@ mod tests { }; let mut logger = Logger::new(Mode::Buffer); let resp = runner.run(&request, &mut logger); - assert_eq!(resp.exit_code, ERROR_EXIT_CODE); + assert_eq!(resp.failure_phase, FailurePhase::Rejected); + assert_eq!(resp.exit_code, FailurePhase::Rejected.failure_exit_code()); assert!(resp.error_message.contains(ERR_PROXY_POLICY)); } @@ -1338,12 +1363,26 @@ mod tests { // -- NanVixError display tests --------------------------------------------- #[test] - fn error_display_preflight() { - let err = NanVixError::Preflight("missing binary".to_string()); - assert!(err.to_string().contains("preflight")); + fn error_display_policy() { + let err = NanVixError::Policy("workingDirectory".to_string()); + assert!(err.to_string().contains("policy")); + assert!(err.to_string().contains("workingDirectory")); + } + + #[test] + fn error_display_unavailable() { + let err = NanVixError::Unavailable("missing binary".to_string()); + assert!(err.to_string().contains("not available on this host")); assert!(err.to_string().contains("missing binary")); } + #[test] + fn error_display_setup() { + let err = NanVixError::Setup("snapshot home".to_string()); + assert!(err.to_string().contains("setup")); + assert!(err.to_string().contains("snapshot home")); + } + #[test] fn error_display_platform() { let err = NanVixError::Platform("spawn failed".to_string()); @@ -1370,9 +1409,53 @@ mod tests { assert!(msg.contains("5000")); } + #[test] + fn refused_policy_is_reported_as_a_rejection() { + let resp = NanVixError::Policy(ERR_WORKDIR.to_string()).to_response(); + assert_eq!(resp.failure_phase, FailurePhase::Rejected); + assert_eq!(resp.exit_code, 1); + assert!(!resp.error_message.is_empty()); + } + + #[test] + fn missing_runtime_artifacts_blame_the_host() { + let resp = NanVixError::Unavailable("nanvixd not found".to_string()).to_response(); + assert_eq!(resp.failure_phase, FailurePhase::BackendUnavailable); + assert_eq!(resp.exit_code, ERROR_EXIT_CODE); + } + + #[test] + fn run_faults_keep_the_generic_backend_error_code() { + for (err, phase) in [ + ( + NanVixError::Setup("staging".to_string()), + FailurePhase::LaunchFailed, + ), + ( + NanVixError::Platform("spawn".to_string()), + FailurePhase::LaunchFailed, + ), + ( + NanVixError::Runtime("crash".to_string()), + FailurePhase::PostLaunchFailed, + ), + ( + NanVixError::Timeout { + script_timeout_ms: 1, + total_ms: 2, + }, + FailurePhase::Timeout, + ), + ] { + let resp = err.to_response(); + assert_eq!(resp.failure_phase, phase, "{err}"); + assert_eq!(resp.exit_code, ERROR_EXIT_CODE, "{err}"); + } + } + #[test] fn error_to_response_has_error_exit_code() { - let err = NanVixError::Preflight("test".to_string()); + let err = NanVixError::Setup("test".to_string()); let resp = err.to_response(); assert_eq!(resp.exit_code, ERROR_EXIT_CODE); assert!(!resp.error_message.is_empty()); diff --git a/src/mxc-sdk/src/backends/process_container/common/appcontainer_runner.rs b/src/mxc-sdk/src/backends/process_container/common/appcontainer_runner.rs index a3cff3cac..47d7fafd3 100644 --- a/src/mxc-sdk/src/backends/process_container/common/appcontainer_runner.rs +++ b/src/mxc-sdk/src/backends/process_container/common/appcontainer_runner.rs @@ -85,6 +85,29 @@ const PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT: u32 = 1; /// Proxy-related env var names to strip/override when building the child env block. const PROXY_VAR_NAMES: &[&str] = &["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "ALL_PROXY"]; +const BFSCFG_MISSING_MSG: &str = + "Filesystem policy error: bfscfg.exe is not available on this Windows build, so the \ + AppContainer + BFS filesystem tier cannot enforce your policy. Use an OS build that \ + includes bfscfg.exe, or run on a host that supports the BaseContainer backend (which does \ + not require bfscfg.exe)."; + +/// A failure while bringing the sandbox up, before the child could run. +fn launch_failed(message: &str) -> ScriptResponse { + ScriptResponse { + failure_phase: FailurePhase::LaunchFailed, + ..ScriptResponse::error(message) + } +} + +/// Attribute a BFS configuration failure: an absent `bfscfg.exe` is a host that +/// cannot provide this tier at all, not a policy this run could restate. +fn bfs_configure_failure(error: &WxcError) -> ScriptResponse { + match error { + WxcError::BfsNotAvailable => ScriptResponse::unavailable(BFSCFG_MISSING_MSG), + other => launch_failed(&other.to_string()), + } +} + fn create_process_failure( err: &windows_core::Error, command_line: &str, @@ -1559,7 +1582,7 @@ impl AppContainerScriptRunner { } if let Err(e) = self.initialize(request) { - return Err(ScriptResponse::error(&e.to_string())); + return Err(launch_failed(&e.to_string())); } let principal_id = self.get_principal_id(); @@ -1570,7 +1593,9 @@ impl AppContainerScriptRunner { let bfscfg_path = if self.filesystem_mode == FilesystemMode::Bfs { match crate::process_container_common::fallback_detector::find_bfscfg_exe() { Ok(p) => p, - Err(e) => return Err(ScriptResponse::error(&e.to_string())), + // The only failure is an OS that cannot report its own install + // directory, which no request can work around. + Err(e) => return Err(ScriptResponse::unavailable(&e.to_string())), } } else { None @@ -1580,16 +1605,7 @@ impl AppContainerScriptRunner { FileSystemBfsManager::new(self.app_container_name.clone(), bfscfg_path); if self.filesystem_mode == FilesystemMode::Bfs { if let Err(e) = bfs_manager.configure(&request.policy, logger) { - let msg = if matches!(&e, WxcError::BfsNotAvailable) { - "Filesystem policy error: bfscfg.exe is not available on this Windows \ - build, so the AppContainer + BFS filesystem tier cannot enforce your \ - policy. Use an OS build that includes bfscfg.exe, or run on a host that \ - supports the BaseContainer backend (which does not require bfscfg.exe)." - .to_string() - } else { - e.to_string() - }; - return Err(ScriptResponse::error(&msg)); + return Err(bfs_configure_failure(&e)); } } @@ -1640,7 +1656,7 @@ impl AppContainerScriptRunner { self.proxy_address = proxy_coordinator.address().cloned(); } Err(err) => { - return Err(ScriptResponse::error(&err.to_string())); + return Err(launch_failed(&err.to_string())); } } @@ -1816,12 +1832,12 @@ impl SandboxBackend for AppContainerScriptRunner { Ok(c) => c, Err(e) => { self.teardown(&mut prepared, request.lifecycle.preserve_policy, logger); - return Err(ScriptResponse::error(&e.to_string())); + return Err(launch_failed(&e.to_string())); } }; if let Err(e) = child.resume() { self.teardown(&mut prepared, request.lifecycle.preserve_policy, logger); - return Err(ScriptResponse::error(&e.to_string())); + return Err(launch_failed(&e.to_string())); } Ok(Box::new(AppContainerSandboxProcess::new( @@ -2271,6 +2287,23 @@ impl Drop for AppContainerSandboxProcess { mod tests { use crate::mxc_common::audit::{TeardownSkipReason, TeardownStatus}; + #[test] + fn a_missing_bfscfg_is_an_unavailable_host_not_a_rejected_policy() { + use super::*; + + let unavailable = bfs_configure_failure(&WxcError::BfsNotAvailable); + assert_eq!(unavailable.failure_phase, FailurePhase::BackendUnavailable); + assert_eq!( + unavailable.exit_code, + FailurePhase::BackendUnavailable.failure_exit_code() + ); + assert!(unavailable.error_message.contains("bfscfg.exe")); + + let launch = bfs_configure_failure(&WxcError::FilesystemPolicy("add failed".to_string())); + assert_eq!(launch.failure_phase, FailurePhase::LaunchFailed); + assert!(launch.error_message.contains("add failed")); + } + #[test] fn appcontainer_network_audit_records_proxyless_proxy_and_start_failure() { use super::*; diff --git a/src/mxc-sdk/src/backends/process_container/common/base_container_runner.rs b/src/mxc-sdk/src/backends/process_container/common/base_container_runner.rs index 4ad7ec097..8998ce4ff 100644 --- a/src/mxc-sdk/src/backends/process_container/common/base_container_runner.rs +++ b/src/mxc-sdk/src/backends/process_container/common/base_container_runner.rs @@ -141,6 +141,9 @@ const PSEC_ENUMERATE_PATHS_UNSUPPORTED_MSG: &str = const PSEC_INGRESS_UNSUPPORTED_MSG: &str = "network.ingress.hostLoopback='allow' requires Process Security Environment contract version \ 1.1 with ingress support"; +const PSEC_BASELINE_UNSUPPORTED_MSG: &str = + "this host does not support the baseline Process Security Environment contract version, so \ + the BaseContainer backend cannot be provided"; const CREATE_PROCESS_IN_SECURITY_ENVIRONMENT_API: &str = "CreateProcessW(PROC_THREAD_ATTRIBUTE_SECURITY_ENVIRONMENT)"; @@ -428,6 +431,25 @@ impl BaseContainerRunner { version } + /// Attribute an unsupported PSEC version: a version raised by a requested + /// policy feature is the request's fault, while an unsupported baseline + /// means the host does not implement the contract at all. + fn unsupported_psec_version_response( + request: &ExecutionRequest, + psec_version: SecurityEnvironmentVersion, + ) -> ScriptResponse { + if psec_version == SecurityEnvironmentVersion::V1_0 { + return ScriptResponse::unavailable(PSEC_BASELINE_UNSUPPORTED_MSG); + } + ScriptResponse::rejected(if !request.policy.enumerate_paths.is_empty() { + PSEC_ENUMERATE_PATHS_UNSUPPORTED_MSG + } else if unrestricted_host_loopback_allowed(&request.policy) { + PSEC_INGRESS_UNSUPPORTED_MSG + } else { + "the required Process Security Environment schema version is not supported" + }) + } + /// The single BaseContainer serviceability decision point: return the first /// reason the request cannot be enforced, or /// [`BaseContainerRequestDecision::Serviceable`] when every check passes. @@ -521,14 +543,9 @@ impl BaseContainerRunner { )) })?; if !version_supported { - return Err(ScriptResponse::rejected( - if !request.policy.enumerate_paths.is_empty() { - PSEC_ENUMERATE_PATHS_UNSUPPORTED_MSG - } else if unrestricted_host_loopback_allowed(&request.policy) { - PSEC_INGRESS_UNSUPPORTED_MSG - } else { - "the required Process Security Environment schema version is not supported" - }, + return Err(Self::unsupported_psec_version_response( + request, + psec_version, )); } @@ -2112,6 +2129,36 @@ mod tests { use crate::process_security_environment_spec::process_security_environment_layout as psec_layout; use std::sync::atomic::{AtomicUsize, Ordering}; + /// A host that does not implement the baseline contract cannot be fixed by + /// rewriting the request; a version raised by a requested feature can. + #[test] + fn an_unsupported_psec_baseline_is_unavailable_not_rejected() { + let plain = ExecutionRequest::default(); + let baseline = BaseContainerRunner::unsupported_psec_version_response( + &plain, + SecurityEnvironmentVersion::V1_0, + ); + assert_eq!(baseline.failure_phase, FailurePhase::BackendUnavailable); + assert_eq!( + baseline.exit_code, + FailurePhase::BackendUnavailable.failure_exit_code() + ); + + let enumerate = ExecutionRequest { + policy: ContainerPolicy { + enumerate_paths: vec![r"C:\project".to_string()], + ..Default::default() + }, + ..Default::default() + }; + let raised = BaseContainerRunner::unsupported_psec_version_response( + &enumerate, + SecurityEnvironmentVersion::V1_1, + ); + assert_eq!(raised.failure_phase, FailurePhase::Rejected); + assert_eq!(raised.error_message, PSEC_ENUMERATE_PATHS_UNSUPPORTED_MSG); + } + #[test] fn native_capture_probe_requires_base_container_and_complete_learning_mode_abi() { assert!(!BaseContainerRunner::native_capture_available_with( diff --git a/src/mxc-sdk/src/backends/process_container/common/dispatcher.rs b/src/mxc-sdk/src/backends/process_container/common/dispatcher.rs index 322cfcf5c..44ce539a8 100644 --- a/src/mxc-sdk/src/backends/process_container/common/dispatcher.rs +++ b/src/mxc-sdk/src/backends/process_container/common/dispatcher.rs @@ -75,6 +75,7 @@ use crate::mxc_common::error::WxcError; use crate::mxc_common::filesystem_dacl::{DaclError, DaclManager, RO_MASK, RW_MASK}; use crate::mxc_common::logger::Logger; use crate::mxc_common::models::{ContainmentBackend, ExecutionRequest, ScriptResponse}; +use crate::mxc_common::mxc_error::{MxcError, MxcErrorCode}; use crate::mxc_common::sandbox_process::{Runner, SandboxBackend, SandboxProcess, StdioMode}; use crate::mxc_common::script_runner::ScriptRunner; use crate::mxc_common::validator::NetworkPolicySupport; @@ -284,6 +285,39 @@ impl std::fmt::Display for DispatchError { impl std::error::Error for DispatchError {} +impl DispatchError { + /// The wire code this dispatch failure is reported under. + /// + /// The dividing question is whether a changed request could succeed. A + /// capability the host's Windows build cannot represent is still a refusal + /// of the policy, because dropping the offending field runs; a missing + /// permission or an unresolvable system directory is not, because no + /// request reaches it. + pub fn error_code(&self) -> MxcErrorCode { + match self { + DispatchError::Fallback( + FallbackError::DaclFallbackDisabled + | FallbackError::EnumeratePathsUnsupported + | FallbackError::IngressUnsupported, + ) + | DispatchError::CaptureDenialsUnsupported { .. } + | DispatchError::CaptureDenialsUnavailable { .. } => MxcErrorCode::PolicyValidation, + DispatchError::Fallback( + FallbackError::WriteDacUnavailable { .. } + | FallbackError::SystemRootUnresolved { .. }, + ) => MxcErrorCode::BackendUnavailable, + DispatchError::Sid(error) => error.error_code(), + DispatchError::Dacl { .. } => MxcErrorCode::BackendError, + } + } + + /// Convert to the typed error carried on the wire, keeping the `Display` + /// text as the message. + pub fn to_mxc_error(&self) -> MxcError { + MxcError::new(self.error_code(), self.to_string()) + } +} + impl From for DispatchError { fn from(e: FallbackError) -> Self { DispatchError::Fallback(e) @@ -820,6 +854,79 @@ mod tests { BcUsableGuard, CaptureCapabilityGuard, ForceTierGuard, ENV_LOCK, }; + #[test] + fn a_capability_this_windows_build_cannot_represent_is_a_refusal_of_the_policy() { + // Dropping the offending field runs, so the caller is the one who can + // act. These used to reach the wire as `backend_unavailable`, which + // told the caller to give up on the host instead. + for error in [ + FallbackError::EnumeratePathsUnsupported, + FallbackError::IngressUnsupported, + FallbackError::DaclFallbackDisabled, + ] { + let dispatch = DispatchError::Fallback(error); + assert_eq!( + dispatch.error_code(), + MxcErrorCode::PolicyValidation, + "{dispatch}" + ); + assert_eq!(dispatch.error_code().exit_code(), 1, "{dispatch}"); + } + + for dispatch in [ + DispatchError::CaptureDenialsUnsupported { + tier: IsolationTier::AppContainerDacl, + }, + DispatchError::CaptureDenialsUnavailable { + tier: IsolationTier::AppContainerDacl, + reason: "WPR is not installed".to_string(), + }, + ] { + assert_eq!( + dispatch.error_code(), + MxcErrorCode::PolicyValidation, + "{dispatch}" + ); + } + } + + #[test] + fn a_host_the_request_cannot_reach_is_not_blamed_on_the_policy() { + for error in [ + FallbackError::WriteDacUnavailable { + path: PathBuf::from("C:\\work"), + reason: "access denied".to_string(), + }, + FallbackError::SystemRootUnresolved { + reason: "GetWindowsDirectoryW failed".to_string(), + }, + ] { + let dispatch = DispatchError::Fallback(error); + assert_eq!( + dispatch.error_code(), + MxcErrorCode::BackendUnavailable, + "{dispatch}" + ); + assert_eq!(dispatch.error_code().exit_code(), -1, "{dispatch}"); + } + } + + #[test] + fn a_failed_ace_apply_is_a_runtime_fault() { + let dispatch = DispatchError::Dacl { + error: DaclError::PathNotFound(PathBuf::from("C:\\work")), + warnings: Vec::new(), + }; + assert_eq!(dispatch.error_code(), MxcErrorCode::BackendError); + assert_eq!(dispatch.error_code().exit_code(), -1); + } + + #[test] + fn the_message_the_caller_already_sees_is_the_one_carried_on_the_wire() { + let dispatch = DispatchError::Fallback(FallbackError::IngressUnsupported); + assert_eq!(dispatch.to_mxc_error().message, dispatch.to_string()); + } + fn test_request(policy: ContainerPolicy) -> ExecutionRequest { ExecutionRequest { container_id: "MxcDispatcherTest".to_string(), diff --git a/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs b/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs index b6f1e8dca..52e8ece78 100644 --- a/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs +++ b/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs @@ -31,7 +31,9 @@ use std::time::{Duration, Instant}; use crate::mxc_common::interruptible_reader::{wrap_pipe, InterruptibleReader, ReadCanceller}; use crate::mxc_common::logger::Logger; -use crate::mxc_common::models::{ExecutionRequest, LaunchMethod, ProxyAddress, ScriptResponse}; +use crate::mxc_common::models::{ + ExecutionRequest, FailurePhase, LaunchMethod, ProxyAddress, ScriptResponse, +}; use crate::mxc_common::sandbox_process::{ boxed_closer, cancel_and_join_discard, duplicate_and_take_native_stdio, group_kill, spawn_discard, take_boxed_read, take_boxed_write, wait_with_timeout, NativeStdio, PtySize, @@ -244,6 +246,11 @@ extern "C" { /// from inside the sandbox. const DEFAULT_SHELL: &str = "/bin/sh"; +/// Apple's sandbox launcher, used verbatim by the `open` launch path's helper +/// script and treated as the backend's availability marker: a macOS install +/// without it cannot provide Seatbelt at all. +const SANDBOX_EXEC: &str = "/usr/bin/sandbox-exec"; + /// Markers bracketing the generated profile in the log stream, so a reader can /// slice it out. The `Seatbelt: ` prefix cannot collide with profile content: /// rules start with `(` and profile comments with `;;`. @@ -278,9 +285,9 @@ impl SandboxBackend for SeatbeltScriptRunner { // Seatbelt's own invariants — the only home for them, so a caller that // builds an ExecutionRequest directly gets the same rules. crate::seatbelt_common::seatbelt_policy::validate_seatbelt_network_policy(&request.policy) - .map_err(error_response)?; + .map_err(rejected_response)?; crate::seatbelt_common::seatbelt_policy::validate_seatbelt_ui_policy(request) - .map_err(error_response)?; + .map_err(rejected_response)?; Ok(()) } @@ -293,6 +300,7 @@ impl SandboxBackend for SeatbeltScriptRunner { ) -> Result, ScriptResponse> { validate_common(request)?; self.validate(request)?; + ensure_sandbox_exec_present()?; // The caller manages the proxy. Scope the profile's reachability rule // to its address and inject HTTP_PROXY / HTTPS_PROXY. macOS has no @@ -307,7 +315,8 @@ impl SandboxBackend for SeatbeltScriptRunner { crate::mxc_common::proxy_env::redact_proxy_url(&address.to_url()) )); } - let profile = build_profile_with_proxy(request, proxy_address).map_err(error_response)?; + let profile = + build_profile_with_proxy(request, proxy_address).map_err(rejected_response)?; log_generated_profile(&profile, logger); // Determine launch method + GUI access from the seatbelt config. @@ -351,7 +360,7 @@ fn spawn_exec( } else { "a caller-owned PTY" }; - return Err(error_response(format!( + return Err(rejected_response(format!( "Seatbelt guiAccess requires inherited stdio and cannot use {mode}" ))); } @@ -393,7 +402,7 @@ fn spawn_exec( ) { Ok(cwd) => cwd, Err(e) => { - return Err(error_response(format!( + return Err(launch_failed_response(format!( "failed to read the current directory to anchor the relative seatbelt working directory: {e}" ))) } @@ -428,7 +437,7 @@ fn spawn_exec( let mut child = command .spawn() - .map_err(|error| error_response(spawn_error(&error)))?; + .map_err(|error| launch_failed_response(spawn_error(&error)))?; let (stdin, stdout, stderr) = match stdio { StdioMode::Pipes => (child.stdin.take(), child.stdout.take(), child.stderr.take()), @@ -449,7 +458,7 @@ fn spawn_exec( let _ = child.kill(); let _ = child.wait(); let error = out_result.err().or(err_result.err()); - return Err(error_response(format!( + return Err(launch_failed_response(format!( "Seatbelt: failed to wrap stdio pipes: {}", error.map_or_else(|| "unknown error".to_string(), |e| e.to_string()), ))); @@ -495,7 +504,7 @@ fn spawn_open( } else { "expose a caller-owned PTY" }; - return Err(error_response(format!( + return Err(rejected_response(format!( "Seatbelt launchMethod 'open' launches Terminal.app and cannot {mode}" ))); } @@ -508,7 +517,11 @@ fn spawn_open( // 1. Write the profile to a secure temp file. let profile_path = match write_secure_temp_file("mxc_sb_profile_", profile, 0o600) { Ok(p) => p, - Err(e) => return Err(error_response(format!("failed to write profile: {e}"))), + Err(e) => { + return Err(launch_failed_response(format!( + "failed to write profile: {e}" + ))) + } }; // 2. Resolve the working directory the way the exec path does. Terminal @@ -525,14 +538,14 @@ fn spawn_open( Ok(cwd) => cwd, Err(e) => { let _ = fs::remove_file(&profile_path); - return Err(error_response(format!( + return Err(launch_failed_response(format!( "failed to read the current directory to anchor the relative seatbelt working directory: {e}" ))); } }; if let Some(reason) = working_directory_error(&cwd) { let _ = fs::remove_file(&profile_path); - return Err(error_response(reason)); + return Err(rejected_response(reason)); } // An unresolved cwd starts the child at `/`, which is no one's home. let home_dir = resolved_cwd.as_ref().map(|_| cwd.clone()); @@ -566,7 +579,7 @@ fn spawn_open( Ok(p) => p, Err(e) => { let _ = fs::remove_file(&profile_path); - return Err(error_response(format!( + return Err(launch_failed_response(format!( "failed to write helper script: {e}" ))); } @@ -582,14 +595,16 @@ fn spawn_open( let _ = fs::remove_file(&p); let _ = fs::remove_file(&profile_path); let _ = fs::remove_file(&helper_path); - return Err(error_response(format!("failed to rename to .command: {e}"))); + return Err(launch_failed_response(format!( + "failed to rename to .command: {e}" + ))); } new_path } Err(e) => { let _ = fs::remove_file(&profile_path); let _ = fs::remove_file(&helper_path); - return Err(error_response(format!( + return Err(launch_failed_response(format!( "failed to write .command file: {e}" ))); } @@ -608,7 +623,9 @@ fn spawn_open( Ok(c) => c, Err(e) => { cleanup_files(&[&profile_path, &helper_path, &command_path]); - return Err(error_response(format!("failed to launch via open: {e}"))); + return Err(launch_failed_response(format!( + "failed to launch via open: {e}" + ))); } }; @@ -973,8 +990,9 @@ fn build_sandbox_command( pty_size: Option, logger: &mut Logger, ) -> Result<(Command, Option), ScriptResponse> { - let profile_cstr = CString::new(profile) - .map_err(|e| error_response(format!("seatbelt profile contains embedded NUL byte: {e}")))?; + let profile_cstr = CString::new(profile).map_err(|e| { + rejected_response(format!("seatbelt profile contains embedded NUL byte: {e}")) + })?; let _ = writeln!(logger, "Seatbelt: applying sandbox via sandbox_init"); @@ -1025,7 +1043,9 @@ fn build_sandbox_command( }, &[], ) - .map_err(|error| error_response(format!("Seatbelt: failed to allocate PTY: {error}"))) + .map_err(|error| { + launch_failed_response(format!("Seatbelt: failed to allocate PTY: {error}")) + }) }) .transpose()?; @@ -1071,14 +1091,40 @@ fn log_generated_profile(profile: &str, logger: &mut Logger) { let _ = writeln!(logger, "{PROFILE_LOG_END}"); } -fn error_response(message: String) -> ScriptResponse { +/// A request this backend refuses: the host is fine, so only a changed request +/// can succeed. +fn rejected_response(message: String) -> ScriptResponse { + ScriptResponse::rejected(&message) +} + +/// A fault while bringing the sandboxed child up, after the request was +/// accepted. +fn launch_failed_response(message: String) -> ScriptResponse { ScriptResponse { - exit_code: -1, + exit_code: FailurePhase::LaunchFailed.failure_exit_code(), error_message: message, + failure_phase: FailurePhase::LaunchFailed, ..Default::default() } } +/// Checked before any profile is generated so a host missing Apple's sandbox +/// tooling is reported as an unavailable backend rather than as a failed +/// launch. +fn ensure_sandbox_exec_present() -> Result<(), ScriptResponse> { + ensure_sandbox_exec_at(Path::new(SANDBOX_EXEC)) +} + +fn ensure_sandbox_exec_at(path: &Path) -> Result<(), ScriptResponse> { + if path.is_file() { + return Ok(()); + } + Err(ScriptResponse::unavailable(&format!( + "Seatbelt is unavailable on this host: {} is missing", + path.display() + ))) +} + /// The optional run timeout — `None` when `scriptTimeout` is 0 (wait forever). fn timeout_from(request: &ExecutionRequest) -> Option { if request.script_timeout == 0 { @@ -1199,7 +1245,7 @@ fn build_helper_script( cd '{cwd}' || {{ echo 'mxc: cannot enter working directory {cwd}' >&2; exit 1; }}\n\ PWD='{cwd}'\n\ export PWD\n\ - exec /usr/bin/sandbox-exec -f '{profile}' /bin/sh -c 'clear; {script}'\n", + exec {SANDBOX_EXEC} -f '{profile}' /bin/sh -c 'clear; {script}'\n", profile = escape_single_quoted(profile_path), script = escape_single_quoted(script_code), ) @@ -1338,6 +1384,8 @@ mod tests { .err() .expect("guiAccess PTY must be rejected"); assert!(error.error_message.contains("caller-owned PTY")); + assert_eq!(error.failure_phase, FailurePhase::Rejected); + assert_eq!(error.exit_code, FailurePhase::Rejected.failure_exit_code()); } #[test] @@ -1353,6 +1401,37 @@ mod tests { .err() .expect("open PTY must be rejected"); assert!(error.error_message.contains("caller-owned PTY")); + assert_eq!(error.failure_phase, FailurePhase::Rejected); + assert_eq!(error.exit_code, FailurePhase::Rejected.failure_exit_code()); + } + + #[test] + fn a_host_without_sandbox_exec_blames_the_host() { + let response = ensure_sandbox_exec_at(Path::new("/nonexistent/sandbox-exec")) + .expect_err("a missing sandbox-exec must be reported"); + assert_eq!(response.failure_phase, FailurePhase::BackendUnavailable); + assert_eq!( + response.failure_phase.error_code(), + crate::mxc_common::mxc_error::MxcErrorCode::BackendUnavailable + ); + assert_eq!(response.exit_code, -1); + assert!(response.error_message.contains("sandbox-exec")); + } + + #[test] + fn the_sandbox_exec_check_passes_on_a_stock_macos_host() { + assert!(ensure_sandbox_exec_present().is_ok()); + } + + #[test] + fn a_failed_launch_keeps_the_generic_backend_error_code() { + let response = launch_failed_response("failed to spawn sandboxed process".into()); + assert_eq!(response.failure_phase, FailurePhase::LaunchFailed); + assert_eq!( + response.failure_phase.error_code(), + crate::mxc_common::mxc_error::MxcErrorCode::BackendError + ); + assert_eq!(response.exit_code, -1); } #[test] @@ -1535,6 +1614,11 @@ mod tests { assert!(response.error_message.contains("hostLoopback='allow'")); assert!(response.error_message.contains("default='deny'")); assert!(!response.error_message.contains("inherit 'default'")); + assert_eq!(response.failure_phase, FailurePhase::Rejected); + assert_eq!( + response.exit_code, + FailurePhase::Rejected.failure_exit_code() + ); } /// The other divergent pair is expressible and must be accepted: it is the diff --git a/src/mxc-sdk/src/backends/windows_sandbox/lifecycle/error.rs b/src/mxc-sdk/src/backends/windows_sandbox/lifecycle/error.rs index cbe9d4186..abba99327 100644 --- a/src/mxc-sdk/src/backends/windows_sandbox/lifecycle/error.rs +++ b/src/mxc-sdk/src/backends/windows_sandbox/lifecycle/error.rs @@ -59,7 +59,7 @@ impl OneShotError { }; ScriptResponse { - exit_code: -1, + exit_code: failure_phase.failure_exit_code(), standard_err: summary.to_string(), error_message: summary.to_string(), extended_error: detail, @@ -72,10 +72,11 @@ impl OneShotError { /// whether a retry could ever succeed. fn failure_phase(&self) -> FailurePhase { match self { - // Non-retryable preflight: the request/config cannot be honored or a - // required host prerequisite is missing. Retrying the same input on - // the same host will not succeed. - OneShotError::SandboxUnavailable(_) | OneShotError::Policy(_) => FailurePhase::Rejected, + // The optional OS feature is absent or disabled, so the host has to + // be provisioned; no change to the request reaches it. + OneShotError::SandboxUnavailable(_) => FailurePhase::BackendUnavailable, + // The request asks for something this backend will not do. + OneShotError::Policy(_) => FailurePhase::Rejected, // Launch attempt failed (incl. transient single-instance contention, // async-runtime setup, capture-proof, rendezvous wait, and the // initial guest connect) — generally worth retrying. @@ -91,6 +92,7 @@ impl OneShotError { #[cfg(test)] mod tests { use super::*; + use crate::mxc_common::mxc_error::MxcErrorCode; #[test] fn busy_maps_to_launch_failed_with_detail() { @@ -102,18 +104,23 @@ mod tests { } #[test] - fn policy_and_prereq_errors_map_to_rejected() { - for err in [ - OneShotError::Policy("denied path in share".to_string()), - OneShotError::SandboxUnavailable("feature off".to_string()), - ] { - let resp = err.into_response(); - assert_eq!( - resp.failure_phase, - FailurePhase::Rejected, - "non-retryable preflight should map to Rejected" - ); - } + fn a_refused_policy_is_told_apart_from_a_host_that_cannot_run_it() { + let refused = OneShotError::Policy("denied path in share".to_string()).into_response(); + assert_eq!(refused.failure_phase, FailurePhase::Rejected); + assert_eq!(refused.exit_code, 1); + assert_eq!( + refused.failure_phase.error_code(), + MxcErrorCode::PolicyValidation + ); + + let unavailable = + OneShotError::SandboxUnavailable("feature off".to_string()).into_response(); + assert_eq!(unavailable.failure_phase, FailurePhase::BackendUnavailable); + assert_eq!(unavailable.exit_code, -1); + assert_eq!( + unavailable.failure_phase.error_code(), + MxcErrorCode::BackendUnavailable + ); } #[test] diff --git a/src/mxc-sdk/src/backends/windows_sandbox/lifecycle/state_aware.rs b/src/mxc-sdk/src/backends/windows_sandbox/lifecycle/state_aware.rs index 8ca669f7d..0e106ee8d 100644 --- a/src/mxc-sdk/src/backends/windows_sandbox/lifecycle/state_aware.rs +++ b/src/mxc-sdk/src/backends/windows_sandbox/lifecycle/state_aware.rs @@ -210,10 +210,12 @@ fn is_valid_sandbox_token(token: &str) -> bool { } /// Map a [`OneShotError`] from policy planning to the wire error model. Policy -/// rejections become `policy_validation`; anything else is a backend error. +/// rejections become `policy_validation`, a missing host feature becomes +/// `backend_unavailable`, and anything else is a backend error. fn map_policy_error(e: OneShotError) -> MxcError { match e { OneShotError::Policy(msg) => MxcError::policy_validation(msg), + OneShotError::SandboxUnavailable(msg) => MxcError::backend_unavailable(msg), other => MxcError::backend_error(format!("{other:?}")), } } @@ -1325,6 +1327,12 @@ mod tests { assert_eq!(err.code, MxcErrorCode::PolicyValidation); } + #[test] + fn map_policy_error_blames_the_host_for_a_missing_sandbox_feature() { + let err = map_policy_error(OneShotError::SandboxUnavailable("feature off".into())); + assert_eq!(err.code, MxcErrorCode::BackendUnavailable); + } + #[test] fn validate_start_rejects_non_default_network() { let backend = WindowsSandboxRunner::new(); diff --git a/src/mxc-sdk/src/backends/wslc/common/error.rs b/src/mxc-sdk/src/backends/wslc/common/error.rs index 5a3c9f5b2..8e187947c 100644 --- a/src/mxc-sdk/src/backends/wslc/common/error.rs +++ b/src/mxc-sdk/src/backends/wslc/common/error.rs @@ -74,7 +74,7 @@ impl WslcError { let failure_phase = self.failure_phase(); let msg = self.to_string(); ScriptResponse { - exit_code: -1, + exit_code: failure_phase.failure_exit_code(), standard_err: msg.clone(), error_message: msg, failure_phase, @@ -162,15 +162,37 @@ mod tests { #[test] fn response_matches_the_untyped_shape_apart_from_the_phase() { - let typed = WslcError::Rejected("bad path".to_string()).into_response(); - let untyped = ScriptResponse::error("bad path"); + let typed = WslcError::Host("bring-up failed".to_string()).into_response(); + let untyped = ScriptResponse::error("bring-up failed"); assert_eq!(typed.exit_code, untyped.exit_code); assert_eq!(typed.standard_err, untyped.standard_err); assert_eq!(typed.error_message, untyped.error_message); // The untyped helper left this at the default, which is the bug. assert_eq!(untyped.failure_phase, FailurePhase::None); - assert_eq!(typed.failure_phase, FailurePhase::Rejected); + assert_eq!(typed.failure_phase, FailurePhase::LaunchFailed); + } + + /// A WSLc rejection has to cost the caller the same exit code a rejection + /// from any other backend does. + #[test] + fn exit_codes_follow_the_shared_phase_contract() { + let s = || "x".to_string(); + assert_eq!( + WslcError::Rejected(s()).into_response().exit_code, + ScriptResponse::rejected("x").exit_code + ); + assert_eq!( + WslcError::Unavailable(s()).into_response().exit_code, + ScriptResponse::unavailable("x").exit_code + ); + for err in [ + WslcError::Host(s()), + WslcError::Runtime(s()), + WslcError::sdk("op", -1, ""), + ] { + assert_eq!(err.into_response().exit_code, -1); + } } #[test] diff --git a/src/mxc-sdk/src/backends/wslc/common/wsl_container_runner.rs b/src/mxc-sdk/src/backends/wslc/common/wsl_container_runner.rs index 4037824c1..e7edd50e2 100644 --- a/src/mxc-sdk/src/backends/wslc/common/wsl_container_runner.rs +++ b/src/mxc-sdk/src/backends/wslc/common/wsl_container_runner.rs @@ -1617,7 +1617,15 @@ mod tests { let mut runner = WSLContainerRunner::new(&WslcConfig::default()); let response = runner.execute(&request, &mut logger); - assert_eq!(response.exit_code, -1, "overlap must fail the run"); + assert_eq!( + response.exit_code, + crate::mxc_common::models::FailurePhase::Rejected.failure_exit_code(), + "overlap must fail the run" + ); + assert_eq!( + response.failure_phase, + crate::mxc_common::models::FailurePhase::Rejected + ); assert!( response.error_message.contains("cannot be enforced"), "expected the overlap error, got: {}", @@ -1666,9 +1674,14 @@ mod tests { let response = runner.execute(&request, &mut logger); assert_eq!( - response.exit_code, -1, + response.exit_code, + crate::mxc_common::models::FailurePhase::Rejected.failure_exit_code(), "junction-aliased deny must fail the run" ); + assert_eq!( + response.failure_phase, + crate::mxc_common::models::FailurePhase::Rejected + ); assert!( response.error_message.contains("cannot be enforced"), "expected the overlap error, got: {}", @@ -1716,9 +1729,14 @@ mod tests { let response = runner.execute(&request, &mut logger); assert_eq!( - response.exit_code, -1, + response.exit_code, + crate::mxc_common::models::FailurePhase::Rejected.failure_exit_code(), "absent junction-aliased deny must fail the run" ); + assert_eq!( + response.failure_phase, + crate::mxc_common::models::FailurePhase::Rejected + ); assert!( response.error_message.contains("cannot be enforced"), "expected the overlap error, got: {}", diff --git a/src/mxc-sdk/src/core/mxc_common/models.rs b/src/mxc-sdk/src/core/mxc_common/models.rs index 8817c1080..ef491e57d 100644 --- a/src/mxc-sdk/src/core/mxc_common/models.rs +++ b/src/mxc-sdk/src/core/mxc_common/models.rs @@ -1222,8 +1222,14 @@ pub struct ScriptResponse { /// Non-fatal diagnostics raised while building or running the sandbox, /// carried on the response so a programmatic caller can filter them /// instead of scraping stderr. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub warnings: Vec, + /// + /// Boxed, like `output_metadata`, to keep `ScriptResponse` small enough to + /// serve as a `Result` error type. Reach for it through + /// [`warnings`](ScriptResponse::warnings) and + /// [`extend_warnings`](ScriptResponse::extend_warnings) rather than the + /// field, so the "absent" and "empty" spellings cannot drift apart. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub warnings: Option>>, /// Structured metadata produced after the sandboxed process exits. #[serde(default, skip_serializing_if = "Option::is_none")] pub output_metadata: Option>, @@ -1238,7 +1244,7 @@ impl Default for ScriptResponse { error_message: String::new(), extended_error: String::new(), failure_phase: FailurePhase::None, - warnings: Vec::new(), + warnings: None, output_metadata: None, } } @@ -1335,6 +1341,22 @@ impl ScriptResponse { ..Default::default() } } + + /// The non-fatal diagnostics this run recorded. + pub fn warnings(&self) -> &[String] { + self.warnings.as_deref().map_or(&[], Vec::as_slice) + } + + /// Append diagnostics, allocating only once there is something to record. + pub fn extend_warnings(&mut self, warnings: impl IntoIterator) { + let mut incoming = warnings.into_iter().peekable(); + if incoming.peek().is_none() { + return; + } + self.warnings + .get_or_insert_with(Box::default) + .extend(incoming); + } } #[cfg(test)] @@ -1398,14 +1420,31 @@ mod tests { let quiet = serde_json::to_string(&ScriptResponse::default()).unwrap(); assert!(!quiet.contains("warnings"), "{quiet}"); - let noisy = serde_json::to_string(&ScriptResponse { - warnings: vec!["dacl augmentation fell back".to_string()], - ..Default::default() - }) - .unwrap(); + let mut noisy = ScriptResponse::default(); + noisy.extend_warnings(["dacl augmentation fell back".to_string()]); + let noisy = serde_json::to_string(&noisy).unwrap(); assert!(noisy.contains("dacl augmentation fell back"), "{noisy}"); } + #[test] + fn recording_nothing_allocates_nothing() { + let mut response = ScriptResponse::default(); + response.extend_warnings(Vec::new()); + assert!(response.warnings.is_none()); + assert!(response.warnings().is_empty()); + } + + // `ScriptResponse` is used as a `Result` error type, so it has to stay + // under clippy's `result_large_err` threshold. + #[test] + fn the_response_stays_small_enough_to_be_an_error_type() { + assert!( + std::mem::size_of::() <= 128, + "{} bytes", + std::mem::size_of::() + ); + } + #[test] fn rejection_exits_one_and_leaves_stderr_to_the_envelope() { let rejected = ScriptResponse::rejected("policy not supported"); diff --git a/src/mxc-sdk/src/core/mxc_engine/dispatch.rs b/src/mxc-sdk/src/core/mxc_engine/dispatch.rs index 914a3aad9..a820b1ca0 100644 --- a/src/mxc-sdk/src/core/mxc_engine/dispatch.rs +++ b/src/mxc-sdk/src/core/mxc_engine/dispatch.rs @@ -315,7 +315,7 @@ fn spawn_process_container( let _ = writeln!(logger, "dacl warning: {w}"); } } - Err(MxcError::backend_unavailable(format!("{e}"))) + Err(e.to_mxc_error()) } Err(SpawnDispatchError::Spawn { response, diff --git a/src/mxc-sdk/src/core/mxc_engine/run.rs b/src/mxc-sdk/src/core/mxc_engine/run.rs index f10e51128..3646e98ed 100644 --- a/src/mxc-sdk/src/core/mxc_engine/run.rs +++ b/src/mxc-sdk/src/core/mxc_engine/run.rs @@ -80,9 +80,12 @@ impl ResolvedRunner { /// Experimental backends, classified by the engine's backend registry, require /// `request.experimental_enabled`; without it they return a /// [`backend_unavailable`](MxcError::backend_unavailable) error before any -/// host-specific resolution. Backends that are not available on this host / -/// not compiled in return an -/// [`unsupported_containment`](MxcError::unsupported_containment) error. +/// host-specific resolution. A backend that this build did not compile in +/// reports the same code, since only a rebuilt executor can serve it. A +/// backend that belongs to a different operating system, or one that does not +/// exist yet, reports +/// [`unsupported_containment`](MxcError::unsupported_containment) instead, +/// because naming a backend this host does support will succeed. pub fn resolve_runner( request: &ExecutionRequest, logger: &mut Logger, @@ -254,7 +257,7 @@ fn resolve_runner_inner_windows( let _ = writeln!(logger, "dacl warning: {w}"); } } - Err(MxcError::backend_unavailable(format!("{e}"))) + Err(e.to_mxc_error()) } } } @@ -270,7 +273,7 @@ fn resolve_runner_inner_windows( #[cfg(not(feature = "wslc"))] { let _ = logger; - Err(MxcError::unsupported_containment( + Err(MxcError::backend_unavailable( "WSLC backend not compiled. Rebuild with --features wslc.", )) } @@ -296,7 +299,7 @@ fn resolve_runner_inner_windows( } #[cfg(not(feature = "microvm"))] { - Err(MxcError::unsupported_containment( + Err(MxcError::backend_unavailable( "MicroVM backend not compiled in (build with --features microvm)", )) } @@ -329,7 +332,7 @@ fn resolve_runner_inner_windows( } #[cfg(not(feature = "isolation_session"))] { - Err(MxcError::unsupported_containment( + Err(MxcError::backend_unavailable( "IsolationSession backend not compiled. Rebuild with --features isolation_session.", )) } @@ -360,7 +363,7 @@ fn resolve_runner_inner( } #[cfg(not(feature = "microvm"))] { - Err(MxcError::unsupported_containment( + Err(MxcError::backend_unavailable( "MicroVM backend not compiled in (build with --features microvm)", )) } @@ -502,8 +505,9 @@ fn resolve_hyperlight() -> Result { } #[cfg(not(all(feature = "hyperlight", target_arch = "x86_64")))] { - Err(MxcError::unsupported_containment( - "Hyperlight backend requires x86_64 (Hyperlight needs KVM or WHP)", + Err(MxcError::backend_unavailable( + "Hyperlight backend not compiled in; it requires x86_64 and --features hyperlight \ + (Hyperlight needs KVM or WHP).", )) } } diff --git a/src/tools/lxc/src/main.rs b/src/tools/lxc/src/main.rs index 795538f1c..9be150311 100644 --- a/src/tools/lxc/src/main.rs +++ b/src/tools/lxc/src/main.rs @@ -9,7 +9,10 @@ use clap::Parser; use mxc_sdk::mxc_common::config_parser::load_one_shot_request; use mxc_sdk::mxc_common::logger::{Logger, Mode}; use mxc_sdk::mxc_common::models::{ExecutionRequest, ScriptResponse}; -use mxc_sdk::mxc_common::script_runner::handle_dry_run_exit; +use mxc_sdk::mxc_common::mxc_error::MxcError; +use mxc_sdk::mxc_common::script_runner::{ + emit_mxc_error_exit, emit_warning_envelopes, handle_dry_run_exit, +}; use mxc_sdk::mxc_common::telemetry; use mxc_sdk::lxc_common::signal_cleanup; @@ -106,9 +109,7 @@ fn display_script_results(response: &ScriptResponse, logger: &mut Logger) { /// `lxc-exec` *does* own its terminal, so it opts in here — matching wxc-exec. /// stderr, not stdout: stdout carries the workload's own output. fn emit_warnings(logger: &Logger) { - for warning in logger.warnings() { - eprintln!("{warning}"); - } + emit_warning_envelopes(logger.warnings()); } fn delete_lxc_container(name: &str, logger: &mut Logger) -> bool { @@ -140,8 +141,9 @@ fn main() { // refuse to continue without it because containers leaked on SIGTERM/INT // are exactly the failure mode this code exists to prevent. if let Err(e) = signal_cleanup::install() { - eprintln!("Error: failed to install signal cleanup handler: {}", e); - process::exit(1); + emit_mxc_error_exit(&MxcError::backend_error(format!( + "failed to install signal cleanup handler: {e}" + ))); } let cli = Cli::parse(); @@ -153,8 +155,9 @@ fn main() { match mxc_sdk::mxc_engine::to_json_pretty(&mxc_sdk::mxc_engine::available_backends()) { Ok(json) => println!("{json}"), Err(e) => { - eprintln!("Error: probe serialization failed: {e}"); - process::exit(1); + emit_mxc_error_exit(&MxcError::backend_error(format!( + "probe serialization failed: {e}" + ))); } } return; @@ -231,8 +234,9 @@ fn main() { } else if let Some(ref path) = cli.config_path { (path.clone(), false) } else if !cli.delete { - eprintln!("Error: No config provided. Use a positional path, --config, or --config-base64"); - process::exit(1); + emit_mxc_error_exit(&MxcError::malformed_request( + "No config provided. Use a positional path, --config, or --config-base64", + )); } else { (String::new(), false) }; @@ -245,7 +249,7 @@ fn main() { if let Some(ref log_path) = cli.log_file { if let Err(e) = logger.enable_file_sink(std::path::Path::new(log_path)) { - eprintln!("Warning: could not open log file '{}': {}", log_path, e); + emit_warning_envelopes(&[format!("could not open log file '{log_path}': {e}")]); } } @@ -254,8 +258,9 @@ fn main() { let name = match cli.containername { Some(ref n) => n.as_str(), None => { - eprintln!("Error: --containername is required with --delete"); - process::exit(1); + emit_mxc_error_exit(&MxcError::malformed_request( + "--containername is required with --delete", + )); } }; let success = delete_lxc_container(name, &mut logger); @@ -266,9 +271,9 @@ fn main() { // Load request let mut request = match load_one_shot_request(&config_data, &mut logger, is_base64) { Ok(r) => r, - Err(_) => { - eprint!("Request error\n{}", logger.get_buffer()); - process::exit(1); + Err(error) => { + eprint!("{}", logger.get_buffer()); + emit_mxc_error_exit(&error.to_mxc_error()); } }; @@ -308,7 +313,6 @@ fn main() { let response = match mxc_sdk::mxc_engine::run(&request, &mut logger) { Ok(response) => response, Err(e) => { - eprintln!("error: {}", e.message); emit_warnings(&logger); eprint!("{}", logger.get_buffer()); telemetry::emit_early_exit_with_kind( @@ -317,7 +321,7 @@ fn main() { requested_sandbox_kind, telemetry::FailureReason::InitError, ); - process::exit(1); + emit_mxc_error_exit(&e.into_mxc_error()); } }; let run_elapsed = run_start.elapsed(); diff --git a/src/tools/mxc_darwin/src/main.rs b/src/tools/mxc_darwin/src/main.rs index f017a763a..64bbdb8bc 100644 --- a/src/tools/mxc_darwin/src/main.rs +++ b/src/tools/mxc_darwin/src/main.rs @@ -10,12 +10,13 @@ //! use still requires macOS. use std::fmt::Write; -use std::process; use clap::Parser; use mxc_sdk::mxc_common::config_parser::load_one_shot_request; use mxc_sdk::mxc_common::logger::{Logger, Mode}; use mxc_sdk::mxc_common::models::ExecutionRequest; +use mxc_sdk::mxc_common::mxc_error::MxcError; +use mxc_sdk::mxc_common::script_runner::{emit_mxc_error_exit, emit_warning_envelopes}; #[cfg(target_os = "macos")] use mxc_sdk::mxc_common::models::ScriptResponse; @@ -84,8 +85,9 @@ fn main() { } else if let Some(ref path) = cli.config_path { (path.clone(), false) } else { - eprintln!("Error: No config provided. Use a positional path, --config, or --config-base64"); - process::exit(1); + emit_mxc_error_exit(&MxcError::malformed_request( + "No config provided. Use a positional path, --config, or --config-base64", + )); }; let mut logger = Logger::new(if cli.debug { @@ -96,15 +98,15 @@ fn main() { if let Some(ref log_path) = cli.log_file { if let Err(e) = logger.enable_file_sink(std::path::Path::new(log_path)) { - eprintln!("Warning: could not open log file '{}': {}", log_path, e); + emit_warning_envelopes(&[format!("could not open log file '{log_path}': {e}")]); } } let mut request = match load_one_shot_request(&config_data, &mut logger, is_base64) { Ok(r) => r, - Err(_) => { - eprint!("Request error\n{}", logger.get_buffer()); - process::exit(1); + Err(error) => { + eprint!("{}", logger.get_buffer()); + emit_mxc_error_exit(&error.to_mxc_error()); } }; @@ -150,9 +152,8 @@ fn run_seatbelt(request: &ExecutionRequest, logger: &mut Logger) -> ! { let response = match mxc_sdk::mxc_engine::run(request, logger) { Ok(response) => response, Err(e) => { - eprintln!("error: {}", e.message); eprint!("{}", logger.get_buffer()); - process::exit(1); + emit_mxc_error_exit(&e.into_mxc_error()); } }; let run_elapsed = run_start.elapsed(); @@ -182,15 +183,14 @@ fn run_seatbelt(request: &ExecutionRequest, logger: &mut Logger) -> ! { // with lxc-exec / wxc-exec (issue #564). mxc_sdk::mxc_common::script_runner::emit_backend_error_envelope(&response); - process::exit(response.exit_code); + std::process::exit(response.exit_code); } #[cfg(not(target_os = "macos"))] fn run_seatbelt(_request: &ExecutionRequest, logger: &mut Logger) -> ! { - eprintln!( - "mxc-exec-mac: the macOS sandbox backend is only available on macOS. \ - This binary was built for a non-Darwin target and cannot execute scripts." - ); print!("{}", logger.get_buffer()); - process::exit(1); + emit_mxc_error_exit(&MxcError::backend_unavailable( + "mxc-exec-mac: the macOS sandbox backend is only available on macOS. \ + This binary was built for a non-Darwin target and cannot execute scripts.", + )); } diff --git a/src/tools/wxc/src/main.rs b/src/tools/wxc/src/main.rs index 5383334f6..ad805c7a2 100644 --- a/src/tools/wxc/src/main.rs +++ b/src/tools/wxc/src/main.rs @@ -1132,24 +1132,22 @@ fn main() { // Setup exits before any config is read, so a positional here is // most likely a runtime name given with a space instead of `=`. if let Some(stray) = &cli.config_path { - emit_mxc_error_exit(&MxcError::malformed_request(format!( - "--setup-hyperlight takes no config path; name runtimes with \ + eprintln!( + "Error: --setup-hyperlight takes no config path; name runtimes with \ --setup-hyperlight={stray}" - ))); + ); + process::exit(1); } #[cfg(all(feature = "hyperlight", target_arch = "x86_64"))] { // WHP is delay-loaded; check before setup boots a VM. #[cfg(target_os = "windows")] if !mxc_sdk::hyperlight_common::is_whp_available() { - emit_mxc_error_exit( - &MxcError::backend_unavailable( - "--setup-hyperlight requires Windows Hypervisor Platform (WHP)", - ) - .with_remediation( - "Enable the HypervisorPlatform optional feature and reboot.", - ), + eprintln!( + "Error: --setup-hyperlight requires Windows Hypervisor Platform (WHP). \ + Enable the HypervisorPlatform optional feature and reboot." ); + process::exit(1); } // Setup is an interactive install: the pull and the warm-up @@ -1157,24 +1155,27 @@ fn main() { let mut logger = Logger::new(Mode::Console); let runtimes = match mxc_sdk::hyperlight_common::parse_runtimes(runtime_names) { Ok(runtimes) => runtimes, - Err(msg) => emit_mxc_error_exit(&MxcError::malformed_request(msg)), + Err(msg) => { + eprintln!("Error: {msg}"); + process::exit(1); + } }; match mxc_sdk::hyperlight_common::setup(cli.force, &runtimes, &mut logger) { Ok(home) => { eprintln!("hyperlight setup: image home ready at {:?}", home); process::exit(0); } - Err(msg) => emit_mxc_error_exit(&MxcError::backend_error(format!( - "hyperlight setup failed: {msg}" - ))), + Err(msg) => { + eprintln!("hyperlight setup failed: {msg}"); + process::exit(1); + } } } #[cfg(not(all(feature = "hyperlight", target_arch = "x86_64")))] { let _ = runtime_names; - emit_mxc_error_exit(&MxcError::backend_unavailable( - "--setup-hyperlight requires x86_64 (Hyperlight needs KVM or WHP)", - )); + eprintln!("Error: --setup-hyperlight requires x86_64 (Hyperlight needs KVM or WHP)"); + process::exit(1); } } @@ -1186,9 +1187,10 @@ fn main() { { let image = match cli.image.as_deref() { Some(s) if !s.is_empty() => s, - _ => emit_mxc_error_exit(&MxcError::malformed_request( - "--setup-wslc requires --image ", - )), + _ => { + eprintln!("Error: --setup-wslc requires --image "); + process::exit(1); + } }; let mut logger = Logger::new(if cli.debug { Mode::Console @@ -1215,16 +1217,15 @@ fn main() { match result { Ok(()) => process::exit(0), Err(msg) => { - emit_mxc_error_exit(&MxcError::backend_error(format!("wslc setup failed: {msg}"))) + eprintln!("wslc setup failed: {msg}"); + process::exit(1); } } } #[cfg(not(feature = "wslc"))] { - emit_mxc_error_exit( - &MxcError::backend_unavailable("WSLC backend not compiled") - .with_remediation("Rebuild with --features wslc."), - ); + eprintln!("Error: WSLC backend not compiled. Rebuild with --features wslc."); + process::exit(1); } } @@ -1650,8 +1651,8 @@ fn main() { // opts in here. Warnings travel on the response so a programmatic caller // reads them from one structured place rather than scraping stderr. // Emitted before the dry-run branch below, which exits the process. - response.warnings.extend(logger.take_warnings()); - mxc_sdk::mxc_common::script_runner::emit_warning_envelopes(&response.warnings); + response.extend_warnings(logger.take_warnings()); + mxc_sdk::mxc_common::script_runner::emit_warning_envelopes(response.warnings()); #[cfg(target_os = "windows")] if let Err(error) = mxc_sdk::mxc_engine::emit_verbose_telemetry( From 1067ca9b1deba43c739f0d6b5d62d7a68c3fc04b Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:39:42 -0700 Subject: [PATCH 03/17] Skip capture assertions where the host reports backend_unavailable The P14b and P14c behavioural assertions gate on the `nativeCaptureAvailable` probe, which answers whether the Learning Mode API is present. That is not the same question as whether it is implemented: on a host whose exports resolve but return E_NOTIMPL, the probe says the provider is usable, both runs then fail before an output path exists, and P14c reports three failures on empty strings. The runtime already classifies that outcome correctly -- `is_api_unavailable()` maps it to `BackendUnavailable`, which reaches stderr as a typed `backend_unavailable` envelope. Gate on what the run reported rather than on the probe alone, via a shared `Test-BackendUnavailable`. Keying on the typed code covers every reason a host cannot provide capture, not just the ones a probe knows to ask about. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../lib/WinProcessContainer.Common.ps1 | 18 +++++++++++ ..._processcontainer_capture_denials_test.ps1 | 31 ++++++++++++++----- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/tests/scripts/lib/WinProcessContainer.Common.ps1 b/tests/scripts/lib/WinProcessContainer.Common.ps1 index 19c1884ec..edfecefe2 100644 --- a/tests/scripts/lib/WinProcessContainer.Common.ps1 +++ b/tests/scripts/lib/WinProcessContainer.Common.ps1 @@ -956,6 +956,24 @@ function Test-WasRejected { $both -match ('"code"\s*:\s*"(' + $rejectionCodes + ')"')) } +# A run the host could not service at all, reported as a typed +# `backend_unavailable` envelope on stderr. +# +# Host capability probes answer whether an API is present, which is not the +# same question as whether it is implemented: an export can resolve and still +# return E_NOTIMPL when called. Behavioural assertions that need a working +# provider therefore gate on what a real run reported rather than on the probe +# alone, so such a host skips instead of failing on absent output. +function Test-BackendUnavailable { + param( + [Parameter(Mandatory)] [object]$Run, + [AllowEmptyString()] [string]$Log + ) + $result = $(if ($Run.PSObject.Properties['Result']) { $Run.Result } else { $Run }) + $err = "$(if ($result.PSObject.Properties['Stderr']) { $result.Stderr })" + return [bool]("$Log`n$err" -match '"code"\s*:\s*"backend_unavailable"') +} + # The non-network counterpart of Get-NetVerdict's NORUN state. # # Every negative assertion is satisfied by a workload that never started, so diff --git a/tests/scripts/run_processcontainer_capture_denials_test.ps1 b/tests/scripts/run_processcontainer_capture_denials_test.ps1 index 60195d9b7..dfdf7a35d 100644 --- a/tests/scripts/run_processcontainer_capture_denials_test.ps1 +++ b/tests/scripts/run_processcontainer_capture_denials_test.ps1 @@ -6,8 +6,9 @@ # `outputPath` is validated by validate_capture_denials_output_path during # config parsing, so Phase 14a's four rejections never reach the launch API — # rare process-container coverage that still produces signal on a host which -# cannot start a container. The behavioral half (14b/14c) is host-dependent -# and fails honestly where a run cannot start. +# cannot start a container. The behavioral half (14b/14c) is host-dependent: +# it skips where the host reports `backend_unavailable` for capture, and fails +# honestly where a run cannot start for any other reason. # # Documented contract (docs/schema.md, updated 2026-09-10): # * mode "block" (default) keeps access denied and logs it; "allow" permits @@ -162,7 +163,13 @@ function Phase-CaptureDenialsModes { -CaptureDenialsMode 'allow' $log = Join-Path $ScratchRoot 'logs\cd-allow-warning.log' $r = Invoke-Wxc -Wxc $WxcDebug -ConfigPath $cfg -LogPath $log -TimeoutSec 60 - $all = "$(Read-Log $log)`n$($r.Stderr)" + $logText = Read-Log $log + if (Test-BackendUnavailable -Run $r -Log $logText) { + Record-Result -Phase 'P14b' -Name 'captureDenials mode=allow emits a security warning' -Status 'skip' ` + -Detail 'the host reported backend_unavailable for capture, so no capture session exists to warn about' + return + } + $all = "$logText`n$($r.Stderr)" $sawWarning = [bool]($all -match '(?i)warn.{0,80}(denial|deny|audit|allow)|(?i)(denial|audit).{0,80}warn') Record-Result -Phase 'P14b' -Name 'captureDenials mode=allow emits a security warning' ` -Pass $sawWarning ` @@ -182,11 +189,13 @@ function Phase-CaptureDenialsModes { function Phase-CaptureDenialsStampedPath { Section 'Phase 14c: captureDenials per-run output path' + $assertions = @( + 'the actual denials output path is reported on stderr', + 'the reported path is not the verbatim requested path (a per-run id is stamped in)', + 'two runs of the same config report different output paths') + if (-not $Script:Caps.CaptureDenialsUsable) { - foreach ($name in @( - 'the actual denials output path is reported on stderr', - 'the reported path is not the verbatim requested path (a per-run id is stamped in)', - 'two runs of the same config report different output paths')) { + foreach ($name in $assertions) { Record-Result -Phase 'P14c' -Name $name -Status 'skip' ` -Detail 'no capture provider on this host, so the request fails before an output path is resolved' } @@ -207,6 +216,14 @@ function Phase-CaptureDenialsStampedPath { -CaptureDenialsOutputPath $requested $log = Join-Path $ScratchRoot "logs\cd-stamped-$i.log" $r = Invoke-Wxc -Wxc $WxcDebug -ConfigPath $cfg -LogPath $log -TimeoutSec 60 + if (Test-BackendUnavailable -Run $r -Log (Read-Log $log)) { + foreach ($name in $assertions) { + Record-Result -Phase 'P14c' -Name $name -Status 'skip' ` + -Detail ("the host reported backend_unavailable for capture (run $i), " + + 'so no output path is resolved') + } + return + } # stderr only: the one-line pointer is the CLI-boundary contract under # test, and the backend log carries its own copy of the resolved path, # so including the log would keep this green if the pointer were From f9d03b93c426852e8999d2cecbbfeb9ebc8994a5 Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:07:54 -0700 Subject: [PATCH 04/17] Report a tier the host could not build as backend_unavailable Dispatch runs after the policy is accepted, so a failure there is the host failing to build the tier the policy asked for, not a verdict on the request. Classifying fallback and capture-provider failures as policy_validation made wxc-exec log them as unsupported_field_for_backend and exit 1, which the process-container suites read as a validation rejection of a config they require to be accepted. Only a SID that could not be derived or ACEs that could not be applied keep their own codes. Update the harnesses that encoded the previous exit codes: WSLC and isolation session rejections now exit 1, and the two process-container dispatch refusals now exit -1. Match the Bubblewrap cwd advisory piecewise, since the advisory is carried in a JSON warning envelope that escapes the quotes around the path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/development/architecture/versioning.md | 8 ++- .../process_container/common/dispatcher.rs | 71 ++++++++----------- tests/scripts/run_bwrap_cwd_advisory_test.sh | 8 ++- tests/scripts/run_isolation_session_tests.ps1 | 6 +- ...un_processcontainer_dacl_disabled_test.ps1 | 12 ++-- ...esscontainer_filesystem_enumerate_test.ps1 | 9 +-- tests/scripts/run_wslc_all_tests.ps1 | 10 +-- 7 files changed, 60 insertions(+), 64 deletions(-) diff --git a/docs/development/architecture/versioning.md b/docs/development/architecture/versioning.md index 846faae73..c1c904c47 100644 --- a/docs/development/architecture/versioning.md +++ b/docs/development/architecture/versioning.md @@ -717,8 +717,12 @@ classification drives both the wire code and the process exit code: A backend this build did not compile in reports `backend_unavailable`, since only a rebuilt executor can serve it. A backend belonging to another operating system reports `unsupported_containment`, since naming a supported backend -succeeds. Earlier phase-specific codes (`config_parse`, `malformed_request`, -`unsupported_phase`, `malformed_id`) are unchanged and also exit `1`. +succeeds. A tier the host could not build for the policy — an AppContainer +fallback that cannot represent `enumeratePaths`, or an absent denial-capture +provider — is also `backend_unavailable` rather than a rejection, because the +policy was accepted and never judged. Earlier phase-specific codes +(`config_parse`, `malformed_request`, `unsupported_phase`, `malformed_id`) are +unchanged and also exit `1`. `ScriptResponse` carries an optional `warnings` array of diagnostic strings. Warnings are advisory and never change the exit code; they are omitted from the diff --git a/src/mxc-sdk/src/backends/process_container/common/dispatcher.rs b/src/mxc-sdk/src/backends/process_container/common/dispatcher.rs index 44ce539a8..d8fb96dc8 100644 --- a/src/mxc-sdk/src/backends/process_container/common/dispatcher.rs +++ b/src/mxc-sdk/src/backends/process_container/common/dispatcher.rs @@ -288,24 +288,20 @@ impl std::error::Error for DispatchError {} impl DispatchError { /// The wire code this dispatch failure is reported under. /// - /// The dividing question is whether a changed request could succeed. A - /// capability the host's Windows build cannot represent is still a refusal - /// of the policy, because dropping the offending field runs; a missing - /// permission or an unresolvable system directory is not, because no - /// request reaches it. + /// Reaching dispatch means the policy was already accepted, so a failure + /// here is the host failing to build the tier the policy asked for, not a + /// judgement of the policy -- the same distinction the + /// [`RejectionReason::RunnerUnavailable`] audit reason draws. The two + /// exceptions are failures of an operation the host *could* perform: a SID + /// that could not be derived carries its own code, and ACEs that could not + /// be applied are a runtime fault. + /// + /// [`RejectionReason::RunnerUnavailable`]: crate::mxc_common::audit::RejectionReason pub fn error_code(&self) -> MxcErrorCode { match self { - DispatchError::Fallback( - FallbackError::DaclFallbackDisabled - | FallbackError::EnumeratePathsUnsupported - | FallbackError::IngressUnsupported, - ) + DispatchError::Fallback(_) | DispatchError::CaptureDenialsUnsupported { .. } - | DispatchError::CaptureDenialsUnavailable { .. } => MxcErrorCode::PolicyValidation, - DispatchError::Fallback( - FallbackError::WriteDacUnavailable { .. } - | FallbackError::SystemRootUnresolved { .. }, - ) => MxcErrorCode::BackendUnavailable, + | DispatchError::CaptureDenialsUnavailable { .. } => MxcErrorCode::BackendUnavailable, DispatchError::Sid(error) => error.error_code(), DispatchError::Dacl { .. } => MxcErrorCode::BackendError, } @@ -855,24 +851,36 @@ mod tests { }; #[test] - fn a_capability_this_windows_build_cannot_represent_is_a_refusal_of_the_policy() { - // Dropping the offending field runs, so the caller is the one who can - // act. These used to reach the wire as `backend_unavailable`, which - // told the caller to give up on the host instead. + fn a_tier_the_host_could_not_build_is_not_blamed_on_the_policy() { + // Dispatch runs after the policy was accepted, so none of these are a + // verdict on the request. Reporting them as a policy rejection would + // send the caller editing a config that was never the problem. for error in [ FallbackError::EnumeratePathsUnsupported, FallbackError::IngressUnsupported, FallbackError::DaclFallbackDisabled, + FallbackError::WriteDacUnavailable { + path: PathBuf::from("C:\\work"), + reason: "access denied".to_string(), + }, + FallbackError::SystemRootUnresolved { + reason: "GetWindowsDirectoryW failed".to_string(), + }, ] { let dispatch = DispatchError::Fallback(error); assert_eq!( dispatch.error_code(), - MxcErrorCode::PolicyValidation, + MxcErrorCode::BackendUnavailable, "{dispatch}" ); - assert_eq!(dispatch.error_code().exit_code(), 1, "{dispatch}"); + assert_eq!(dispatch.error_code().exit_code(), -1, "{dispatch}"); } + } + #[test] + fn a_host_without_a_capture_provider_has_not_rejected_capture_denials() { + // The suites read acceptance as "not rejected during validation", and + // a host with no provider never got as far as judging the section. for dispatch in [ DispatchError::CaptureDenialsUnsupported { tier: IsolationTier::AppContainerDacl, @@ -882,32 +890,11 @@ mod tests { reason: "WPR is not installed".to_string(), }, ] { - assert_eq!( - dispatch.error_code(), - MxcErrorCode::PolicyValidation, - "{dispatch}" - ); - } - } - - #[test] - fn a_host_the_request_cannot_reach_is_not_blamed_on_the_policy() { - for error in [ - FallbackError::WriteDacUnavailable { - path: PathBuf::from("C:\\work"), - reason: "access denied".to_string(), - }, - FallbackError::SystemRootUnresolved { - reason: "GetWindowsDirectoryW failed".to_string(), - }, - ] { - let dispatch = DispatchError::Fallback(error); assert_eq!( dispatch.error_code(), MxcErrorCode::BackendUnavailable, "{dispatch}" ); - assert_eq!(dispatch.error_code().exit_code(), -1, "{dispatch}"); } } diff --git a/tests/scripts/run_bwrap_cwd_advisory_test.sh b/tests/scripts/run_bwrap_cwd_advisory_test.sh index 9e974eaf2..3b9204cd8 100644 --- a/tests/scripts/run_bwrap_cwd_advisory_test.sh +++ b/tests/scripts/run_bwrap_cwd_advisory_test.sh @@ -37,8 +37,12 @@ EOF for case in uncovered unrelated; do rc=0 output=$("$LXC_EXEC" --experimental "$WORK_DIR/$case.json" 2>"$WORK_DIR/$case.stderr") || rc=$? + # Matched piecewise rather than as one literal line: the advisory is carried + # in a JSON warning envelope, so the quotes around the path are escaped. if [ "$rc" -eq 0 ] \ - || ! grep -Fq "WARNING: Bubblewrap: process.cwd \"$CWD\"" "$WORK_DIR/$case.stderr" \ + || ! grep -Fq '{"warning":' "$WORK_DIR/$case.stderr" \ + || ! grep -Fq 'Bubblewrap: process.cwd' "$WORK_DIR/$case.stderr" \ + || ! grep -Fq "$CWD" "$WORK_DIR/$case.stderr" \ || ! grep -Fq 'filesystem.readonlyPaths' "$WORK_DIR/$case.stderr" \ || ! grep -Eq '(^|[[:space:]])bwrap:' "$WORK_DIR/$case.stderr"; then echo "$case cwd did not report the hint and Bubblewrap's original error: $output" @@ -48,7 +52,7 @@ for case in uncovered unrelated; do done output=$("$LXC_EXEC" --experimental "$WORK_DIR/covered.json" 2>"$WORK_DIR/covered.stderr") if ! grep -qFx "$CWD" <<<"$output" \ - || grep -Fq 'WARNING: Bubblewrap: process.cwd ' "$WORK_DIR/covered.stderr"; then + || grep -Fq 'Bubblewrap: process.cwd' "$WORK_DIR/covered.stderr"; then echo "Covered cwd did not run without an advisory: $output" cat "$WORK_DIR/covered.stderr" exit 1 diff --git a/tests/scripts/run_isolation_session_tests.ps1 b/tests/scripts/run_isolation_session_tests.ps1 index ea0f65904..b92bdb62d 100644 --- a/tests/scripts/run_isolation_session_tests.ps1 +++ b/tests/scripts/run_isolation_session_tests.ps1 @@ -434,7 +434,7 @@ $null = $results.Add((Run-IsolationSessionTest "legacy network posture rejected" # than accepted and dropped. Presence drives the refusal (UiPolicy's default is # full lockdown, so an explicit lockdown `ui` is indistinguishable by value). $null = $results.Add((Run-IsolationSessionTest "isolation_session_one_shot_ui_rejected.json" ` - -ExpectedExit -1 ` + -ExpectedExit 1 ` -OutputContains @("UI policy is not supported"))) # One-shot lifecycle rejection: the in-proc API exposes no session-lifetime @@ -442,11 +442,11 @@ $null = $results.Add((Run-IsolationSessionTest "isolation_session_one_shot_ui_re # returning. `destroyOnExit: true` (the default) matches that and is accepted; # `false` asks for something the backend cannot deliver. $null = $results.Add((Run-IsolationSessionTest "isolation_session_one_shot_lifecycle_rejected.json" ` - -ExpectedExit -1 ` + -ExpectedExit 1 ` -OutputContains @("lifecycle.destroyOnExit=false"))) $null = $results.Add((Run-IsolationSessionTest "isolation_session_one_shot_env_rejected.json" ` - -ExpectedExit -1 ` + -ExpectedExit 1 ` -OutputContains @("process.env without process.inheritDefaultEnv=true is not supported"))) # ---------------- Concurrent one-shot test ---------------- diff --git a/tests/scripts/run_processcontainer_dacl_disabled_test.ps1 b/tests/scripts/run_processcontainer_dacl_disabled_test.ps1 index 50aa1189f..fdf6bf986 100644 --- a/tests/scripts/run_processcontainer_dacl_disabled_test.ps1 +++ b/tests/scripts/run_processcontainer_dacl_disabled_test.ps1 @@ -51,15 +51,15 @@ function Phase-DaclDisabled { # A bare `exit != 0` is satisfied by a launch failure or a harness timeout, # which is the false-green pattern this suite exists to remove. This refusal - # is raised during runner resolution, before any launch API, so wxc-exec - # logs a ConfigRejected event with reason `runner_unavailable` and exits 1. + # is raised during tier selection, before any launch API, so wxc-exec logs a + # ConfigRejected event with reason `runner_unavailable` and exits -1. # Test-WasRejected does not apply here: it deliberately treats - # `runner_unavailable` as a non-rejection, because everywhere else it means - # the host could not build a tier and the policy was never judged. + # `runner_unavailable` as a non-rejection, because it means the host could + # not build a tier and the policy was never judged. $stderrOrLog = ($r.Stderr + "`n" + $logContent) $typedRefusal = [bool]($stderrOrLog -match '"reason"\s*:\s*"runner_unavailable"') - $refused = (-not $r.TimedOut) -and ($r.ExitCode -eq 1) -and $typedRefusal - Record-Result -Phase 'P5' -Name 'dispatch refused before launch (typed runner_unavailable, exit 1)' ` + $refused = (-not $r.TimedOut) -and ($r.ExitCode -eq -1) -and $typedRefusal + Record-Result -Phase 'P5' -Name 'dispatch refused before launch (typed runner_unavailable, exit -1)' ` -Pass $refused ` -Detail "exit=$($r.ExitCode); timedOut=$($r.TimedOut); typedRefusal=$typedRefusal" Record-Result -Phase 'P5' -Name 'rw ACL untouched' -Pass ($aclBefore -eq $aclAfter) diff --git a/tests/scripts/run_processcontainer_filesystem_enumerate_test.ps1 b/tests/scripts/run_processcontainer_filesystem_enumerate_test.ps1 index 6b5deb46d..e28adf755 100644 --- a/tests/scripts/run_processcontainer_filesystem_enumerate_test.ps1 +++ b/tests/scripts/run_processcontainer_filesystem_enumerate_test.ps1 @@ -111,10 +111,11 @@ function Phase-FsEnumerate { $r = Invoke-Wxc -Wxc $WxcDebug -ConfigPath $cfg -LogPath $log -TimeoutSec 30 $combined = "$($r.Stdout)`n$($r.Stderr)`n$(Read-Log $log)" - # wxc-exec exits 1 when it refuses a request; -1 means a launch API - # failed, which would mean the policy was never judged. - Record-Result -Phase 'P4e' -Name 'unsupported host refuses the run (exit=1)' ` - -Pass (-not $r.TimedOut -and $r.ExitCode -eq 1) ` + # enumeratePaths cannot be represented by an AppContainer tier, so the + # host could not build one for this policy: the refusal is typed + # `backend_unavailable` and exits -1. A silent exit 0 is the defect. + Record-Result -Phase 'P4e' -Name 'unsupported host refuses the run (typed backend_unavailable, exit -1)' ` + -Pass (-not $r.TimedOut -and $r.ExitCode -eq -1 -and (Test-BackendUnavailable -Run $r -Log $combined)) ` -Detail "exit=$($r.ExitCode); timedOut=$($r.TimedOut)" Record-Result -Phase 'P4e' -Name 'refusal is attributed to enumeratePaths, not generic' ` -Pass ($combined -match '(?i)enumeratePaths') ` diff --git a/tests/scripts/run_wslc_all_tests.ps1 b/tests/scripts/run_wslc_all_tests.ps1 index 8c18efd39..78d9cfd32 100644 --- a/tests/scripts/run_wslc_all_tests.ps1 +++ b/tests/scripts/run_wslc_all_tests.ps1 @@ -360,13 +360,13 @@ $null = $results.Add((Run-WslcTest "wslc_network_bridged.json" ` # rules, so a per-destination egress rule must be refused rather than quietly # widened to the posture's default. foreach ($ruleConfig in @("wslc_network_egress_rules_rejected.json", "wslc_network_egress_deny_rules_rejected.json")) { - $null = $results.Add((Run-WslcTest $ruleConfig -ExpectedExit -1 ` + $null = $results.Add((Run-WslcTest $ruleConfig -ExpectedExit 1 ` -OutputContains "network.egress allow/deny rules" ` -OutputNotContains "WSLC_EGRESS_RULES_SHOULD_NOT_RUN")) } # `process.env` without `inheritDefaultEnv` is launched through `env -i NAME=VALUE`, # which puts the injected proxy URL in argv and therefore in /proc//cmdline. -$null = $results.Add((Run-WslcTest "wslc_network_proxy_credentials_rejected.json" -ExpectedExit -1 ` +$null = $results.Add((Run-WslcTest "wslc_network_proxy_credentials_rejected.json" -ExpectedExit 1 ` -OutputContains "must not carry credentials" ` -OutputNotContains "WSLC_PROXY_CREDENTIALS_SHOULD_NOT_RUN")) # Delegate the cooperative proxy fixture to its owning script, which asserts @@ -426,11 +426,11 @@ $null = $results.Add((Run-WslcTest "wslc_cold_cache_pull.json" -ForceDebug ` -OutputContains "Image 'busybox:latest' found" -OutputNotContains "Pulling image")) # The store is warm now, so a miss here can only come from the denied posture. Clear-ColdCacheStore -$null = $results.Add((Run-WslcTest "wslc_cold_cache_denied_egress.json" -ExpectedExit -1 ` +$null = $results.Add((Run-WslcTest "wslc_cold_cache_denied_egress.json" -ExpectedExit 1 ` -OutputContains "declares no egress" -OutputNotContains "SHOULD_NOT_RUN")) # `Check the image name and tag` is unique to the non-retryable arm; every arm # says `could not be pulled`. -$null = $results.Add((Run-WslcTest "wslc_cold_cache_unresolvable.json" -ExpectedExit -1 ` +$null = $results.Add((Run-WslcTest "wslc_cold_cache_unresolvable.json" -ExpectedExit 1 ` -OutputContains "Check the image name and tag")) Clear-ColdCacheStore @@ -460,7 +460,7 @@ $null = $results.Add((Run-WslcTest "wslc_destroy_on_exit_true.json" ` # deliver. It is refused before any container is created, so the payload must # never run. $null = $results.Add((Run-WslcTest "wslc_destroy_on_exit_false_rejected.json" ` - -ExpectedExit -1 ` + -ExpectedExit 1 ` -OutputContains "destroyOnExit=false")) Write-Host "`n--- State-Aware Lifecycle Tests ---" -ForegroundColor Cyan From ad5b809c367a009eef6097ac4c1a01f5337f283b Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:37:13 -0700 Subject: [PATCH 05/17] Keep an unrepresentable policy field a rejection, not an unavailable tier Two different failures share DispatchError. A field the selected tier cannot represent names that field and is documented as a rejection, so collapsing it into backend_unavailable made hostLoopback=allow and enumeratePaths exit -1, which the suites read as 'accepted and silently dropped' -- the one outcome P8d and P8e exist to rule out. A tier that could not be built at all, or a missing denial-capture provider, stays backend_unavailable: no tier existed to judge the policy against. Restore the enumeratePaths harness to exit 1 to match. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/development/architecture/versioning.md | 14 +++--- .../process_container/common/dispatcher.rs | 47 ++++++++++++++----- ...esscontainer_filesystem_enumerate_test.ps1 | 10 ++-- 3 files changed, 48 insertions(+), 23 deletions(-) diff --git a/docs/development/architecture/versioning.md b/docs/development/architecture/versioning.md index c1c904c47..d3f75f12e 100644 --- a/docs/development/architecture/versioning.md +++ b/docs/development/architecture/versioning.md @@ -717,12 +717,14 @@ classification drives both the wire code and the process exit code: A backend this build did not compile in reports `backend_unavailable`, since only a rebuilt executor can serve it. A backend belonging to another operating system reports `unsupported_containment`, since naming a supported backend -succeeds. A tier the host could not build for the policy — an AppContainer -fallback that cannot represent `enumeratePaths`, or an absent denial-capture -provider — is also `backend_unavailable` rather than a rejection, because the -policy was accepted and never judged. Earlier phase-specific codes -(`config_parse`, `malformed_request`, `unsupported_phase`, `malformed_id`) are -unchanged and also exit `1`. +succeeds. Within a backend, a field the selected tier cannot represent (for +example `filesystem.enumeratePaths` or `network.ingress.hostLoopback` on an +AppContainer fallback) is a `policy_validation` rejection naming that field, +never a silent drop; but a tier that could not be built at all, or an absent +denial-capture provider, is `backend_unavailable`, because the policy was +accepted and never judged. Earlier phase-specific codes (`config_parse`, +`malformed_request`, `unsupported_phase`, `malformed_id`) are unchanged and +also exit `1`. `ScriptResponse` carries an optional `warnings` array of diagnostic strings. Warnings are advisory and never change the exit code; they are omitted from the diff --git a/src/mxc-sdk/src/backends/process_container/common/dispatcher.rs b/src/mxc-sdk/src/backends/process_container/common/dispatcher.rs index d8fb96dc8..e14ccdd4d 100644 --- a/src/mxc-sdk/src/backends/process_container/common/dispatcher.rs +++ b/src/mxc-sdk/src/backends/process_container/common/dispatcher.rs @@ -288,18 +288,25 @@ impl std::error::Error for DispatchError {} impl DispatchError { /// The wire code this dispatch failure is reported under. /// - /// Reaching dispatch means the policy was already accepted, so a failure - /// here is the host failing to build the tier the policy asked for, not a - /// judgement of the policy -- the same distinction the - /// [`RejectionReason::RunnerUnavailable`] audit reason draws. The two - /// exceptions are failures of an operation the host *could* perform: a SID - /// that could not be derived carries its own code, and ACEs that could not - /// be applied are a runtime fault. + /// A field the selected tier cannot represent is a refusal of the policy: + /// the message names the field, and a request that drops it runs. The rest + /// are the tier itself being unavailable -- none could be built, or a + /// required provider is absent -- so the policy was never judged, which is + /// the same distinction the [`RejectionReason::RunnerUnavailable`] audit + /// reason draws. A SID that could not be derived carries its own code, and + /// ACEs that could not be applied are a runtime fault. /// /// [`RejectionReason::RunnerUnavailable`]: crate::mxc_common::audit::RejectionReason pub fn error_code(&self) -> MxcErrorCode { match self { - DispatchError::Fallback(_) + DispatchError::Fallback( + FallbackError::EnumeratePathsUnsupported | FallbackError::IngressUnsupported, + ) => MxcErrorCode::PolicyValidation, + DispatchError::Fallback( + FallbackError::DaclFallbackDisabled + | FallbackError::WriteDacUnavailable { .. } + | FallbackError::SystemRootUnresolved { .. }, + ) | DispatchError::CaptureDenialsUnsupported { .. } | DispatchError::CaptureDenialsUnavailable { .. } => MxcErrorCode::BackendUnavailable, DispatchError::Sid(error) => error.error_code(), @@ -851,13 +858,28 @@ mod tests { }; #[test] - fn a_tier_the_host_could_not_build_is_not_blamed_on_the_policy() { - // Dispatch runs after the policy was accepted, so none of these are a - // verdict on the request. Reporting them as a policy rejection would - // send the caller editing a config that was never the problem. + fn a_field_the_tier_cannot_represent_is_a_refusal_of_the_policy() { + // Documented as a rejection rather than a silent drop, and the caller + // is the one who can act: a request without the field runs. for error in [ FallbackError::EnumeratePathsUnsupported, FallbackError::IngressUnsupported, + ] { + let dispatch = DispatchError::Fallback(error); + assert_eq!( + dispatch.error_code(), + MxcErrorCode::PolicyValidation, + "{dispatch}" + ); + assert_eq!(dispatch.error_code().exit_code(), 1, "{dispatch}"); + } + } + + #[test] + fn a_tier_that_could_not_be_built_is_not_blamed_on_the_policy() { + // No tier exists to judge the policy against, so reporting a rejection + // would send the caller editing a config that was never the problem. + for error in [ FallbackError::DaclFallbackDisabled, FallbackError::WriteDacUnavailable { path: PathBuf::from("C:\\work"), @@ -895,6 +917,7 @@ mod tests { MxcErrorCode::BackendUnavailable, "{dispatch}" ); + assert_eq!(dispatch.error_code().exit_code(), -1, "{dispatch}"); } } diff --git a/tests/scripts/run_processcontainer_filesystem_enumerate_test.ps1 b/tests/scripts/run_processcontainer_filesystem_enumerate_test.ps1 index e28adf755..ffbeb3f3a 100644 --- a/tests/scripts/run_processcontainer_filesystem_enumerate_test.ps1 +++ b/tests/scripts/run_processcontainer_filesystem_enumerate_test.ps1 @@ -111,11 +111,11 @@ function Phase-FsEnumerate { $r = Invoke-Wxc -Wxc $WxcDebug -ConfigPath $cfg -LogPath $log -TimeoutSec 30 $combined = "$($r.Stdout)`n$($r.Stderr)`n$(Read-Log $log)" - # enumeratePaths cannot be represented by an AppContainer tier, so the - # host could not build one for this policy: the refusal is typed - # `backend_unavailable` and exits -1. A silent exit 0 is the defect. - Record-Result -Phase 'P4e' -Name 'unsupported host refuses the run (typed backend_unavailable, exit -1)' ` - -Pass (-not $r.TimedOut -and $r.ExitCode -eq -1 -and (Test-BackendUnavailable -Run $r -Log $combined)) ` + # enumeratePaths cannot degrade to an AppContainer tier, so the refusal + # names the field and exits 1; -1 would mean a launch API failed, which + # would mean the policy was never judged. + Record-Result -Phase 'P4e' -Name 'unsupported host refuses the run (exit=1)' ` + -Pass (-not $r.TimedOut -and $r.ExitCode -eq 1) ` -Detail "exit=$($r.ExitCode); timedOut=$($r.TimedOut)" Record-Result -Phase 'P4e' -Name 'refusal is attributed to enumeratePaths, not generic' ` -Pass ($combined -match '(?i)enumeratePaths') ` From 1e1923d491ff43372e91d436652fd1651d92f1a6 Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:44:00 -0700 Subject: [PATCH 06/17] Route audit cleanup diagnostics through the warning contract Audit finalization raised its cleanup diagnostics as bare stderr prose, so a caller had to scrape them while every other warning arrived as a JSON envelope. They now travel on the response and are emitted with the rest. The finalization failure arm hard-coded exit -1 regardless of how the backend had already classified the run, which could pair a rejection envelope with an exit status that contradicts it. Deriving the status from the failure phase keeps the two in agreement, and the appended message reaches stderr through the error envelope rather than a separate prose line. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/tools/wxc/src/main.rs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/tools/wxc/src/main.rs b/src/tools/wxc/src/main.rs index ad805c7a2..dd9a0cac5 100644 --- a/src/tools/wxc/src/main.rs +++ b/src/tools/wxc/src/main.rs @@ -1608,22 +1608,28 @@ fn main() { if !cli.dry_run { match audit::finalize(&mut response, context, &exe_dir, cli.audit_verbose) { Ok(cleanup_warnings) => { - for warning in cleanup_warnings { - let message = format!("[audit] warning: {warning}"); - let _ = writeln!(logger, "{message}"); - eprintln!("{message}"); + for warning in &cleanup_warnings { + let _ = writeln!(logger, "[audit] warning: {warning}"); } + response.extend_warnings( + cleanup_warnings + .into_iter() + .map(|warning| format!("[audit] {warning}")), + ); eprintln!("[audit] artifacts written to {}", context.log_dir.display()); } Err(error) => { let message = format!("audit finalization failed: {error}"); let _ = writeln!(logger, "[audit] {message}"); - eprintln!("error: {message}"); - response.exit_code = -1; response.error_message = match response.error_message.is_empty() { true => message, false => format!("{}; {message}", response.error_message), }; + // Derived rather than hard-coded so the status keeps + // agreeing with the code the envelope reports: an + // otherwise-clean run carries `backend_error`, and a run + // the backend already refused stays a rejection. + response.exit_code = response.failure_phase.failure_exit_code(); } } } From a39b1d05782dc67ee1d33f2eb7da519573d99ffa Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:08:16 -0700 Subject: [PATCH 07/17] Apply one classification to the paths that still disagreed Three surfaces reached the same condition by a different route and reported it differently. Shared request validation returned a runtime error, so an empty script was a rejection through `validate` but a `backend_error` through `spawn`, which is the path callers actually take. Both shared checks judge the request, so they are rejections. `enumeratePaths` on a backend that cannot represent it now also matches how the process-container dispatcher classifies the same field. The streaming and PTY arms for a backend this build omits returned `unsupported_containment`, while run-to-completion resolution called the same build and host `backend_unavailable`. The code no longer depends on which operation the caller chose. Arms for a backend belonging to another operating system are untouched. Seatbelt probed for Apple's sandbox CLI on every launch, but only the `Open` method shells out to it; `Exec`, the default, applies the profile through `sandbox_init`. A host without the CLI no longer loses the launch method that does not need it. Documents the stderr warning envelope alongside the response array, since the two shapes differ and only the array was described, and records why a missing experimental opt-in is a host-side code. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/development/architecture/versioning.md | 13 +++++++++-- .../src/backends/lxc/common/lxc_runner.rs | 8 +++++++ .../seatbelt/common/seatbelt_runner.rs | 13 ++++++----- .../src/core/mxc_common/sandbox_process.rs | 8 ++++++- src/mxc-sdk/src/core/mxc_common/validator.rs | 6 +++-- src/mxc-sdk/src/core/mxc_engine/dispatch.rs | 22 ++++++++++++++++--- 6 files changed, 57 insertions(+), 13 deletions(-) diff --git a/docs/development/architecture/versioning.md b/docs/development/architecture/versioning.md index d3f75f12e..13eba2419 100644 --- a/docs/development/architecture/versioning.md +++ b/docs/development/architecture/versioning.md @@ -722,13 +722,22 @@ example `filesystem.enumeratePaths` or `network.ingress.hostLoopback` on an AppContainer fallback) is a `policy_validation` rejection naming that field, never a silent drop; but a tier that could not be built at all, or an absent denial-capture provider, is `backend_unavailable`, because the policy was -accepted and never judged. Earlier phase-specific codes (`config_parse`, +accepted and never judged. An experimental backend named without the +experimental opt-in is also `backend_unavailable`: the opt-in is an executor +invocation mode rather than a policy field, so no change to the authored +request reaches it. Earlier phase-specific codes (`config_parse`, `malformed_request`, `unsupported_phase`, `malformed_id`) are unchanged and also exit `1`. `ScriptResponse` carries an optional `warnings` array of diagnostic strings. Warnings are advisory and never change the exit code; they are omitted from the -wire entirely when a run produced none. +wire entirely when a run produced none. The serialized response and the +executor's stderr stream are two distinct shapes of the same warnings: on the +response they are a `warnings: string[]` field, while an executor writes each +one to stderr as its own line-delimited JSON object, +`{"warning":{"message":"..."}}`, matching the error envelope +`{"error":{"code":"...","message":"..."}}`. A caller reading stderr must parse +the per-line objects; the array exists only on a response it holds directly. The MXC ↔ OS contract therefore reports: which feature failed, whether it was a version mismatch or a runtime/capability unavailability (e.g. Hyper-V off), and diff --git a/src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs b/src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs index afea21dfe..4ccca5caf 100644 --- a/src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs +++ b/src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs @@ -3142,6 +3142,14 @@ mod tests { .contains("Script content must not be empty"), "expected the shared refusal, got: {refusal:?}" ); + // The shared checks judge the request, so they have to reach the + // caller as a rejection on the launch path too, not only through a + // standalone `validate`. + assert_eq!(refusal.failure_phase, FailurePhase::Rejected); + assert_eq!( + refusal.exit_code, + FailurePhase::Rejected.failure_exit_code() + ); } /// A caller can only act on a failure it can attribute: a refused policy is diff --git a/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs b/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs index 52e8ece78..d84988c62 100644 --- a/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs +++ b/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs @@ -300,7 +300,6 @@ impl SandboxBackend for SeatbeltScriptRunner { ) -> Result, ScriptResponse> { validate_common(request)?; self.validate(request)?; - ensure_sandbox_exec_present()?; // The caller manages the proxy. Scope the profile's reachability rule // to its address and inject HTTP_PROXY / HTTPS_PROXY. macOS has no @@ -334,7 +333,10 @@ impl SandboxBackend for SeatbeltScriptRunner { LaunchMethod::Exec => { spawn_exec(&profile, request, gui_access, stdio, logger, proxy_address) } - LaunchMethod::Open => spawn_open(&profile, request, stdio, logger, proxy_address), + LaunchMethod::Open => { + ensure_sandbox_exec_present()?; + spawn_open(&profile, request, stdio, logger, proxy_address) + } } } } @@ -1108,9 +1110,10 @@ fn launch_failed_response(message: String) -> ScriptResponse { } } -/// Checked before any profile is generated so a host missing Apple's sandbox -/// tooling is reported as an unavailable backend rather than as a failed -/// launch. +/// Guards the `Open` launch path, which shells out to Apple's sandbox CLI, so +/// a host missing it is reported as an unavailable backend rather than as a +/// failed launch. `Exec` applies the profile through `sandbox_init` and does +/// not need the CLI. fn ensure_sandbox_exec_present() -> Result<(), ScriptResponse> { ensure_sandbox_exec_at(Path::new(SANDBOX_EXEC)) } diff --git a/src/mxc-sdk/src/core/mxc_common/sandbox_process.rs b/src/mxc-sdk/src/core/mxc_common/sandbox_process.rs index bb0d530b5..5f38a8a6e 100644 --- a/src/mxc-sdk/src/core/mxc_common/sandbox_process.rs +++ b/src/mxc-sdk/src/core/mxc_common/sandbox_process.rs @@ -934,7 +934,13 @@ mod runner_tests { let response = runner.execute(&request, &mut logger); - assert_eq!(response.exit_code, -1); + // An empty script is a fault of the request, so the shared checks + // refuse it before the backend is engaged at all. + assert_eq!(response.failure_phase, FailurePhase::Rejected); + assert_eq!( + response.exit_code, + FailurePhase::Rejected.failure_exit_code() + ); assert_eq!(validations.load(Ordering::Relaxed), 0); assert_eq!(direct_spawns.load(Ordering::Relaxed), 0); } diff --git a/src/mxc-sdk/src/core/mxc_common/validator.rs b/src/mxc-sdk/src/core/mxc_common/validator.rs index b3b359d17..07c9b5793 100644 --- a/src/mxc-sdk/src/core/mxc_common/validator.rs +++ b/src/mxc-sdk/src/core/mxc_common/validator.rs @@ -147,13 +147,15 @@ pub fn validate_state_aware_network_policy_support( /// Validates non-backend-specific parts of the request (e.g. non-empty script). pub fn validate_common(request: &ExecutionRequest) -> Result<(), ScriptResponse> { if request.script_code.is_empty() { - return Err(ScriptResponse::error("Script content must not be empty.")); + return Err(ScriptResponse::rejected( + "Script content must not be empty.", + )); } if !request.policy.enumerate_paths.is_empty() && request.containment != crate::mxc_common::models::ContainmentBackend::ProcessContainer { - return Err(ScriptResponse::error( + return Err(ScriptResponse::rejected( "processContainer.filesystem.enumeratePaths is supported only by the Windows \ ProcessContainer backend", )); diff --git a/src/mxc-sdk/src/core/mxc_engine/dispatch.rs b/src/mxc-sdk/src/core/mxc_engine/dispatch.rs index a820b1ca0..63815255b 100644 --- a/src/mxc-sdk/src/core/mxc_engine/dispatch.rs +++ b/src/mxc-sdk/src/core/mxc_engine/dispatch.rs @@ -368,7 +368,7 @@ fn spawn_wslc( ) -> Result, MxcError> { #[cfg(target_os = "windows")] { - Err(MxcError::unsupported_containment( + Err(MxcError::backend_unavailable( "WSLC backend not compiled. Rebuild with --features wslc.", )) } @@ -420,7 +420,7 @@ fn spawn_isolation_session( ) -> Result, MxcError> { #[cfg(target_os = "windows")] { - Err(MxcError::unsupported_containment( + Err(MxcError::backend_unavailable( "IsolationSession backend not compiled. Rebuild with --features isolation_session.", )) } @@ -440,7 +440,7 @@ fn spawn_isolation_session_pty( ) -> Result, MxcError> { #[cfg(target_os = "windows")] { - Err(MxcError::unsupported_containment( + Err(MxcError::backend_unavailable( "IsolationSession backend not compiled. Rebuild with --features isolation_session.", )) } @@ -602,6 +602,22 @@ mod tests { assert!(err.message.contains("Windows"), "got: {}", err.message); } + #[cfg(all(target_os = "windows", not(feature = "wslc")))] + #[test] + fn streaming_reports_a_backend_this_build_omits_as_unavailable() { + // Run-to-completion resolution calls the same build/host pair + // `backend_unavailable`, so streaming must not hand the caller a + // different code for it. + let mut request = minimal_request(); + request.containment = ContainmentBackend::Wslc; + let mut logger = Logger::new(Mode::Buffer); + let err = match spawn_runner(&request, &mut logger) { + Ok(_) => panic!("a backend this build omits must not spawn"), + Err(e) => e, + }; + assert_eq!(err.code, MxcErrorCode::BackendUnavailable); + } + #[cfg(not(target_os = "linux"))] #[test] fn streaming_rejects_lxc_off_linux() { From fcb5c5a7e002bf3cad741a67708f1d5db5ef92f0 Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:21:32 -0700 Subject: [PATCH 08/17] Remove Seatbelt sandbox-exec availability check launchMethod is rejected by every supported contract, so the Open path it guarded is unreachable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1c677fc2-77c3-43b0-a06f-da11d11447c8 --- docs/backends/seatbelt/seatbelt-backend.md | 1 - .../seatbelt/common/seatbelt_runner.rs | 48 +------------------ 2 files changed, 2 insertions(+), 47 deletions(-) diff --git a/docs/backends/seatbelt/seatbelt-backend.md b/docs/backends/seatbelt/seatbelt-backend.md index 67f10a3bb..b31da22b2 100644 --- a/docs/backends/seatbelt/seatbelt-backend.md +++ b/docs/backends/seatbelt/seatbelt-backend.md @@ -596,7 +596,6 @@ once you can see the rules that were emitted. | A client is configured with `HTTP_PROXY` but reaches nothing | It's ignoring the proxy vars. Outbound is kernel-scoped to the proxy port, so it can't connect anywhere else | Use a proxy-aware client — see [Proxy support](#proxy-support-what-is-and-isnt-enforced) | | A test runner or build tool fails spawning workers | `nestedPty: false`, or its IPC socket sits under a `readonlyPaths`/`deniedPaths` entry | Leave `nestedPty` at `true`; put socket directories in `readwritePaths` | | The same config runs on Linux but is rejected on macOS | A `..` segment in a path — Seatbelt-only rejection | Pass the fully resolved path | -| `Seatbelt is unavailable on this host: /usr/bin/sandbox-exec is missing` | The install has no Apple sandbox tooling, so the backend cannot be provided (`backend_unavailable`, exit `-1`) | Run on a stock macOS install, or pick another backend | ### What gets rejected diff --git a/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs b/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs index d84988c62..a7b50efa0 100644 --- a/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs +++ b/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs @@ -246,11 +246,6 @@ extern "C" { /// from inside the sandbox. const DEFAULT_SHELL: &str = "/bin/sh"; -/// Apple's sandbox launcher, used verbatim by the `open` launch path's helper -/// script and treated as the backend's availability marker: a macOS install -/// without it cannot provide Seatbelt at all. -const SANDBOX_EXEC: &str = "/usr/bin/sandbox-exec"; - /// Markers bracketing the generated profile in the log stream, so a reader can /// slice it out. The `Seatbelt: ` prefix cannot collide with profile content: /// rules start with `(` and profile comments with `;;`. @@ -333,10 +328,7 @@ impl SandboxBackend for SeatbeltScriptRunner { LaunchMethod::Exec => { spawn_exec(&profile, request, gui_access, stdio, logger, proxy_address) } - LaunchMethod::Open => { - ensure_sandbox_exec_present()?; - spawn_open(&profile, request, stdio, logger, proxy_address) - } + LaunchMethod::Open => spawn_open(&profile, request, stdio, logger, proxy_address), } } } @@ -1110,24 +1102,6 @@ fn launch_failed_response(message: String) -> ScriptResponse { } } -/// Guards the `Open` launch path, which shells out to Apple's sandbox CLI, so -/// a host missing it is reported as an unavailable backend rather than as a -/// failed launch. `Exec` applies the profile through `sandbox_init` and does -/// not need the CLI. -fn ensure_sandbox_exec_present() -> Result<(), ScriptResponse> { - ensure_sandbox_exec_at(Path::new(SANDBOX_EXEC)) -} - -fn ensure_sandbox_exec_at(path: &Path) -> Result<(), ScriptResponse> { - if path.is_file() { - return Ok(()); - } - Err(ScriptResponse::unavailable(&format!( - "Seatbelt is unavailable on this host: {} is missing", - path.display() - ))) -} - /// The optional run timeout — `None` when `scriptTimeout` is 0 (wait forever). fn timeout_from(request: &ExecutionRequest) -> Option { if request.script_timeout == 0 { @@ -1248,7 +1222,7 @@ fn build_helper_script( cd '{cwd}' || {{ echo 'mxc: cannot enter working directory {cwd}' >&2; exit 1; }}\n\ PWD='{cwd}'\n\ export PWD\n\ - exec {SANDBOX_EXEC} -f '{profile}' /bin/sh -c 'clear; {script}'\n", + exec /usr/bin/sandbox-exec -f '{profile}' /bin/sh -c 'clear; {script}'\n", profile = escape_single_quoted(profile_path), script = escape_single_quoted(script_code), ) @@ -1408,24 +1382,6 @@ mod tests { assert_eq!(error.exit_code, FailurePhase::Rejected.failure_exit_code()); } - #[test] - fn a_host_without_sandbox_exec_blames_the_host() { - let response = ensure_sandbox_exec_at(Path::new("/nonexistent/sandbox-exec")) - .expect_err("a missing sandbox-exec must be reported"); - assert_eq!(response.failure_phase, FailurePhase::BackendUnavailable); - assert_eq!( - response.failure_phase.error_code(), - crate::mxc_common::mxc_error::MxcErrorCode::BackendUnavailable - ); - assert_eq!(response.exit_code, -1); - assert!(response.error_message.contains("sandbox-exec")); - } - - #[test] - fn the_sandbox_exec_check_passes_on_a_stock_macos_host() { - assert!(ensure_sandbox_exec_present().is_ok()); - } - #[test] fn a_failed_launch_keeps_the_generic_backend_error_code() { let response = launch_failed_response("failed to spawn sandboxed process".into()); From 04d2170ac5fb43f64068156ffd565d9d751988b4 Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:53:57 -0700 Subject: [PATCH 09/17] Changes in response to Gudge's feedback --- .../backends/hyperlight/hyperlight-backend.md | 2 +- .../wslc/wsl-container-getting-started.md | 5 +- .../backends/isolation-session/oneshot.md | 9 +- .../architecture/container-lifecycle.md | 6 +- docs/development/architecture/versioning.md | 33 +++-- .../bubblewrap/common/bwrap_runner.rs | 32 ++++- .../bubblewrap/common/proxy_network.rs | 122 ++++++++++++++---- .../src/backends/lxc/common/availability.rs | 66 +++++++++- .../src/backends/lxc/common/lxc_runner.rs | 17 ++- .../process_container/common/dispatcher.rs | 24 ++-- src/mxc-sdk/src/core/mxc_common/mxc_error.rs | 32 ++++- .../src/core/mxc_common/script_runner.rs | 56 +++++--- src/mxc-sdk/src/core/mxc_engine/dispatch.rs | 10 +- src/mxc-sdk/src/core/mxc_engine/run.rs | 101 ++++++++++++--- src/tools/lxc/src/main.rs | 2 +- src/tools/mxc_darwin/src/main.rs | 4 + src/tools/wxc/src/main.rs | 30 +++-- .../lib/WinProcessContainer.Common.ps1 | 9 +- tests/scripts/lib/seatbelt_common.sh | 4 + tests/scripts/run_bwrap_cwd_advisory_test.sh | 19 ++- tests/scripts/run_bwrap_directional_test.sh | 5 + tests/scripts/run_bwrap_version_gate_test.sh | 4 + ...un_processcontainer_dacl_disabled_test.ps1 | 15 +-- 23 files changed, 466 insertions(+), 141 deletions(-) diff --git a/docs/backends/hyperlight/hyperlight-backend.md b/docs/backends/hyperlight/hyperlight-backend.md index 3bbf1e482..019240954 100644 --- a/docs/backends/hyperlight/hyperlight-backend.md +++ b/docs/backends/hyperlight/hyperlight-backend.md @@ -115,7 +115,7 @@ to the snapshot between calls. | Aspect | Behaviour | |---|---| -| Exit code | The script's: `sys.exit(N)`, `process.exit(N)` or `exit N` gives `N`, an uncaught exception gives `1`, a runner error gives `-1` with the reason in `error_message` | +| Exit code | The script's: `sys.exit(N)`, `process.exit(N)` or `exit N` gives `N`, an uncaught exception gives `1`. A refused request gives `1` and a host or runtime fault gives `-1`, with the reason in `error_message` | | Timeout | `process.timeout` bounds the run; a guest that overruns is interrupted and the run reports `execution timed out` | | stdout / stderr | Inherited by the guest; `ScriptResponse.standard_out` stays empty, so capture at the process level | diff --git a/docs/backends/wslc/wsl-container-getting-started.md b/docs/backends/wslc/wsl-container-getting-started.md index 9cdfee1c1..daefa76e0 100644 --- a/docs/backends/wslc/wsl-container-getting-started.md +++ b/docs/backends/wslc/wsl-container-getting-started.md @@ -290,8 +290,9 @@ request is validated by the same native engine as executor requests. Notes and limits: -- **Windows only.** Selecting `Containment::Wslc` on Linux/macOS, or without the - `wslc` feature, fails with `ErrorCode::UnsupportedContainment`. +- **Windows only.** Selecting `Containment::Wslc` on Linux/macOS fails with + `ErrorCode::UnsupportedContainment`; on Windows without the `wslc` feature it + fails with `ErrorCode::BackendUnavailable`. - **No stdin.** The WSLC SDK exposes no process-input API, so `Sandbox::take_stdin()` returns `None` for a WSL container. - **No host pid.** The process runs inside the WSL VM, so `Sandbox::id()` is `0`; diff --git a/docs/development/architecture/backends/isolation-session/oneshot.md b/docs/development/architecture/backends/isolation-session/oneshot.md index 7cdbbb4ca..dab34c2e5 100644 --- a/docs/development/architecture/backends/isolation-session/oneshot.md +++ b/docs/development/architecture/backends/isolation-session/oneshot.md @@ -255,11 +255,10 @@ the rationale for each disposition, and the error mapping live in | `isolationSession` / one-shot `appId` | rejected as `malformed_request` — IsolationSession one-shot configuration uses only the stable top-level policy | | `processContainer` / `lxc` / `seatbelt` / another backend's section | rejected — only the section matching `containment` is accepted | -Refusals surface as a non-zero exit with the reason on stderr. One-shot has no -typed policy error code: the envelope carries `error.code = "backend_error"` with -the reason in the message. On the state-aware surface, structurally -representable backend policy failures emit `policy_validation`; fields excluded -by an exact phase root fail earlier as `malformed_request`. +Refusals surface as exit `1` with a `policy_validation` error envelope on +stderr naming the reason. An unreachable IsolationSession service reports +`backend_unavailable`. On the state-aware surface, fields excluded by an exact +phase root fail earlier as `malformed_request`. **Why every supplied `ui` is refused.** The `ui` section states intent about the contained code's relationship to the *user's* environment, and was modelled on a diff --git a/docs/development/architecture/container-lifecycle.md b/docs/development/architecture/container-lifecycle.md index 77f063779..6a7092558 100644 --- a/docs/development/architecture/container-lifecycle.md +++ b/docs/development/architecture/container-lifecycle.md @@ -574,9 +574,9 @@ diagnostic. The rejection is recorded through the same `ConfigRejected` audit pa Diagnostics are never duplicated to stdout, and lifecycle stderr is not gated on `--debug`. -Without `--operation`, `wxc-exec` accepts only one-shot requests. Input-source and -pre-parse failures on that path retain the legacy primary diagnostic on stderr and do -not emit a lifecycle envelope. Raw SDK and FFI lifecycle calls do not use this CLI +Without `--operation`, `wxc-exec` accepts only create-and-run requests. Input-source +and pre-parse failures on that path emit a typed `{error}` envelope on stderr rather +than the lifecycle envelope on stdout. Raw SDK and FFI lifecycle calls do not use this CLI stream protocol; they return their status and error data through their binding result surfaces. diff --git a/docs/development/architecture/versioning.md b/docs/development/architecture/versioning.md index 13eba2419..afe7a546e 100644 --- a/docs/development/architecture/versioning.md +++ b/docs/development/architecture/versioning.md @@ -715,19 +715,26 @@ classification drives both the wire code and the process exit code: | The runtime, during an accepted run | all other phases | `backend_error` | `-1` | A backend this build did not compile in reports `backend_unavailable`, since -only a rebuilt executor can serve it. A backend belonging to another operating -system reports `unsupported_containment`, since naming a supported backend -succeeds. Within a backend, a field the selected tier cannot represent (for -example `filesystem.enumeratePaths` or `network.ingress.hostLoopback` on an -AppContainer fallback) is a `policy_validation` rejection naming that field, -never a silent drop; but a tier that could not be built at all, or an absent -denial-capture provider, is `backend_unavailable`, because the policy was -accepted and never judged. An experimental backend named without the -experimental opt-in is also `backend_unavailable`: the opt-in is an executor -invocation mode rather than a policy field, so no change to the authored -request reaches it. Earlier phase-specific codes (`config_parse`, -`malformed_request`, `unsupported_phase`, `malformed_id`) are unchanged and -also exit `1`. +only a rebuilt executor can serve it. On the create-and-run path, a backend +belonging to another operating system reports `unsupported_containment`, since +naming a supported backend succeeds; the container lifecycle operations report +such a backend as `backend_unavailable`. Within a backend, a field the selected +tier cannot represent (for example `filesystem.enumeratePaths` or +`network.ingress.hostLoopback` on an AppContainer fallback), or a fallback the +request forbids (`fallback.allowDaclMutation: false` when only the DACL tier is +usable), is a `policy_validation` rejection naming that field, never a silent +drop; but a tier that could not be built at all, or an absent denial-capture +provider, is `backend_unavailable`, because the policy was accepted and never +judged. An experimental backend named without the experimental opt-in is also +`backend_unavailable`: the opt-in is an executor invocation mode rather than a +policy field, so no change to the authored request reaches it. Earlier +phase-specific codes (`malformed_request`, `unsupported_containment`, +`unsupported_phase`, `malformed_id`) are unchanged and also exit `1`. + +Envelope lines share stderr with the workload's own output, so a workload can +print text that looks like an envelope. Treat stderr envelopes as diagnostics, +not as an authenticated result; the exit code and a held `ScriptResponse` are +authoritative. `ScriptResponse` carries an optional `warnings` array of diagnostic strings. Warnings are advisory and never change the exit code; they are omitted from the diff --git a/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs b/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs index 8505085bb..80ec4248c 100644 --- a/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs +++ b/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs @@ -57,6 +57,16 @@ fn response_with_phase(phase: FailurePhase, message: &str) -> ScriptResponse { } } +/// A host lookup failure is a launch fault, not a refusal of the policy. +fn proxy_resolve_failure_response(error: proxy_network::ProxyResolveError) -> ScriptResponse { + match error { + proxy_network::ProxyResolveError::Rejected(message) => ScriptResponse::rejected(&message), + proxy_network::ProxyResolveError::Lookup(message) => { + response_with_phase(FailurePhase::LaunchFailed, &message) + } + } +} + /// Bubblewrap sandbox runner. Uses only shared `ContainerPolicy` fields — /// no backend-specific config struct required. #[derive(Default)] @@ -469,7 +479,7 @@ impl BubblewrapScriptRunner { })?; Some( proxy_network::SandboxProxy::resolve(address) - .map_err(|error| ScriptResponse::rejected(&error))?, + .map_err(proxy_resolve_failure_response)?, ) } else { None @@ -2178,6 +2188,26 @@ mod tests { } } + #[test] + fn a_failed_proxy_lookup_is_a_launch_fault_not_a_refusal() { + use crate::mxc_common::mxc_error::MxcErrorCode; + use proxy_network::ProxyResolveError; + + let lookup = + proxy_resolve_failure_response(ProxyResolveError::Lookup("dns down".to_string())); + assert_eq!(lookup.failure_phase, FailurePhase::LaunchFailed); + assert_eq!( + lookup.failure_phase.error_code(), + MxcErrorCode::BackendError + ); + assert_eq!(lookup.exit_code, -1); + + let refused = + proxy_resolve_failure_response(ProxyResolveError::Rejected("ipv6".to_string())); + assert_eq!(refused.failure_phase, FailurePhase::Rejected); + assert_eq!(refused.exit_code, 1); + } + #[test] fn a_policy_this_backend_will_not_run_is_blamed_on_the_request() { let runner = BubblewrapScriptRunner::new(); diff --git a/src/mxc-sdk/src/backends/bubblewrap/common/proxy_network.rs b/src/mxc-sdk/src/backends/bubblewrap/common/proxy_network.rs index c51a1468c..17050b2da 100644 --- a/src/mxc-sdk/src/backends/bubblewrap/common/proxy_network.rs +++ b/src/mxc-sdk/src/backends/bubblewrap/common/proxy_network.rs @@ -273,6 +273,35 @@ pub(crate) struct SandboxProxy { egress: ProxyEgress, } +/// Why [`SandboxProxy::resolve`] failed. +#[derive(Debug)] +pub(crate) enum ProxyResolveError { + /// The endpoint, or the address it resolves to, cannot be enforced. + Rejected(String), + /// The host lookup itself did not produce an answer. + Lookup(String), +} + +impl ProxyResolveError { + pub(crate) fn message(&self) -> &str { + match self { + Self::Rejected(message) | Self::Lookup(message) => message, + } + } +} + +impl std::fmt::Display for ProxyResolveError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.message()) + } +} + +impl From for ProxyResolveError { + fn from(message: String) -> Self { + Self::Rejected(message) + } +} + impl SandboxProxy { /// Derive the sandbox-visible proxy from the configured one. /// @@ -280,7 +309,7 @@ impl SandboxProxy { /// an address: the workload then presents the `Host` header and proxy-auth /// realm the operator configured. An IP literal has no name to pin, so a /// loopback literal is rewritten to slirp's gateway instead. - pub(crate) fn resolve(configured: &ProxyAddress) -> Result { + pub(crate) fn resolve(configured: &ProxyAddress) -> Result { Self::resolve_with(configured, resolve_ipv4) } @@ -295,7 +324,7 @@ impl SandboxProxy { if needs_lookup { return Ok(()); } - checked + checked.map_err(|error| error.message().to_string()) } /// Run the static checks, reporting whether a lookup would have followed. @@ -304,11 +333,13 @@ impl SandboxProxy { /// also exactly when a hosts pin is created -- an IP literal resolves to /// `Ok(None)` from [`ProxyAddress::host_pin`]. Callers use the flag to /// reason about the pin without duplicating that classification. - fn inspect_without_resolving(configured: &ProxyAddress) -> (bool, Result<(), String>) { + fn inspect_without_resolving( + configured: &ProxyAddress, + ) -> (bool, Result<(), ProxyResolveError>) { let asked_to_resolve = Cell::new(false); let checked = Self::resolve_with(configured, |_, _| { asked_to_resolve.set(true); - Err(String::new()) + Err(ProxyResolveError::Lookup(String::new())) }); (asked_to_resolve.get(), checked.map(|_| ())) @@ -321,10 +352,14 @@ impl SandboxProxy { /// a lookup whose answer the test does not control. fn resolve_with( configured: &ProxyAddress, - resolve: impl Fn(&str, u16) -> Result, - ) -> Result { + resolve: impl Fn(&str, u16) -> Result, + ) -> Result { if configured.port() == 0 { - return Err("Bubblewrap: proxy-only egress requires a non-zero proxy port".to_string()); + return Err( + "Bubblewrap: proxy-only egress requires a non-zero proxy port" + .to_string() + .into(), + ); } let host = configured.host().trim_matches(['[', ']']); @@ -360,7 +395,7 @@ impl SandboxProxy { // an address nothing answers on -- failing at connect time rather // than at policy time. if ip.is_unspecified() { - return Self::rewritten(configured); + return Ok(Self::rewritten(configured)?); } if ip.is_loopback() { return Err(format!( @@ -368,13 +403,14 @@ impl SandboxProxy { network namespace cannot reach; bind the proxy to 127.0.0.1 or a dual-stack \ wildcard address instead. The egress rule is emitted with IPv4 iptables.", configured.host() - )); + ) + .into()); } - return Err(ipv6_unsupported(host)); + return Err(ipv6_unsupported(host).into()); } if is_reserved_loopback_name { - return Self::rewritten(configured); + return Ok(Self::rewritten(configured)?); } let resolved = resolve(host, configured.port())?; @@ -499,27 +535,29 @@ fn with_deadline( /// `getaddrinfo` cannot be cancelled, so the lookup is moved to a thread and /// abandoned once the bound expires -- the wait is bounded even though the /// query itself keeps running until the resolver gives up. -fn resolve_ipv4(host: &str, port: u16) -> Result { +fn resolve_ipv4(host: &str, port: u16) -> Result { let query = host.to_string(); let waited = with_deadline(RESOLVE_TIMEOUT_LABEL, RESOLVE_TIMEOUT, move || { lookup_ipv4(&query, port) }) .map_err(|error| { - format!("Bubblewrap: could not start the lookup of proxy host '{host}': {error}") + ProxyResolveError::Lookup(format!( + "Bubblewrap: could not start the lookup of proxy host '{host}': {error}" + )) })?; match waited { Ok(resolved) => resolved, - Err(mpsc::RecvTimeoutError::Timeout) => Err(format!( + Err(mpsc::RecvTimeoutError::Timeout) => Err(ProxyResolveError::Lookup(format!( "Bubblewrap: resolving proxy host '{host}' exceeded {}s. The proxy endpoint is \ resolved on the host because DNS is closed inside the sandbox, so an unresponsive \ host resolver blocks the sandbox from starting. Check the host's DNS configuration, \ or give the proxy as an IPv4 address to skip resolution.", RESOLVE_TIMEOUT.as_secs() - )), - Err(mpsc::RecvTimeoutError::Disconnected) => Err(format!( + ))), + Err(mpsc::RecvTimeoutError::Disconnected) => Err(ProxyResolveError::Lookup(format!( "Bubblewrap: the lookup of proxy host '{host}' ended without an answer." - )), + ))), } } @@ -528,14 +566,14 @@ fn resolve_ipv4(host: &str, port: u16) -> Result { /// Only the first IPv4 answer is used, and it is the same one the sandbox is /// pinned to. Opening the rest would widen the chain to addresses the sandbox /// can no longer select: with DNS closed, the pin is its only resolution path. -fn lookup_ipv4(host: &str, port: u16) -> Result { +fn lookup_ipv4(host: &str, port: u16) -> Result { let resolved: Vec = (host, port) .to_socket_addrs() .map_err(|error| { - format!( + ProxyResolveError::Lookup(format!( "Bubblewrap: could not resolve proxy host '{host}': {error}. The proxy endpoint \ is resolved on the host because DNS is closed inside the sandbox." - ) + )) })? .map(|addr| addr.ip()) .collect(); @@ -547,11 +585,11 @@ fn lookup_ipv4(host: &str, port: u16) -> Result { // A name with AAAA records and no A records is the same unenforceable case // as an IPv6 literal, so say so rather than claiming it does not resolve. if resolved.is_empty() { - Err(format!( + Err(ProxyResolveError::Lookup(format!( "Bubblewrap: proxy host '{host}' did not resolve to any address." - )) + ))) } else { - Err(ipv6_unsupported(host)) + Err(ipv6_unsupported(host).into()) } } @@ -2430,6 +2468,9 @@ mod tests { let error = SandboxProxy::resolve_with(&address, resolver([10, 0, 2, 2])) .expect_err("the slirp gateway must not be pinned as if it were a host address"); + let ProxyResolveError::Rejected(error) = error else { + panic!("a reserved answer is a policy refusal: {error}"); + }; assert!( error.contains("10.0.2.0/24") && error.contains("127.0.0.1"), "error should name the reserved range and the supported alternative: {error}" @@ -2503,7 +2544,7 @@ mod tests { /// A resolver that answers every name with `ip`, so pin decisions can be /// tested without a lookup the test does not control. - fn resolver(ip: [u8; 4]) -> impl Fn(&str, u16) -> Result { + fn resolver(ip: [u8; 4]) -> impl Fn(&str, u16) -> Result { move |_, _| Ok(Ipv4Addr::from(ip)) } @@ -2645,7 +2686,7 @@ mod tests { let loopback = ProxyAddress::new("[::1]".into(), 8080); let error = SandboxProxy::resolve(&loopback).unwrap_err(); assert!( - error.contains("IPv4"), + error.message().contains("IPv4"), "error should explain the IPv4 requirement: {error}" ); @@ -2665,12 +2706,39 @@ mod tests { ); let error = SandboxProxy::resolve(&address).unwrap_err(); + // A lookup failure is a host fault, not a refusal of the policy. + let ProxyResolveError::Lookup(error) = error else { + panic!("an unresolvable name is a lookup failure: {error}"); + }; assert!( error.contains("proxy.corp.invalid"), "error should name the offending host: {error}" ); } + #[test] + fn a_failed_lookup_is_not_reported_as_a_refusal() { + let address = ProxyAddress::new("proxy.example".into(), 3128); + let error = SandboxProxy::resolve_with(&address, |_, _| { + Err(ProxyResolveError::Lookup("timed out".into())) + }) + .unwrap_err(); + + assert!(matches!(error, ProxyResolveError::Lookup(_)), "{error}"); + } + + #[test] + fn static_rejections_are_refusals() { + for host in ["[::1]", "2001:db8::1", "10.0.2.2"] { + let address = ProxyAddress::new(host.into(), 8080); + let error = SandboxProxy::resolve(&address).unwrap_err(); + assert!( + matches!(error, ProxyResolveError::Rejected(_)), + "{host}: {error}" + ); + } + } + /// `::1` used to be rewritten to the IPv4 gateway, which handed the sandbox /// an address the proxy never listens on: an IPv6-loopback listener does /// not accept the IPv4 connection slirp's gateway produces. Rejecting at @@ -2682,7 +2750,7 @@ mod tests { let error = SandboxProxy::resolve(&address).unwrap_err(); assert!( - error.contains("IPv6 loopback"), + error.message().contains("IPv6 loopback"), "error should name the unreachable address family: {error}" ); } @@ -2704,7 +2772,7 @@ mod tests { let error = SandboxProxy::resolve(&address).unwrap_err(); assert!( - error.contains("non-zero"), + error.message().contains("non-zero"), "error should explain the port requirement: {error}" ); } diff --git a/src/mxc-sdk/src/backends/lxc/common/availability.rs b/src/mxc-sdk/src/backends/lxc/common/availability.rs index 3aee37b5a..8d69b787b 100644 --- a/src/mxc-sdk/src/backends/lxc/common/availability.rs +++ b/src/mxc-sdk/src/backends/lxc/common/availability.rs @@ -1,7 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +use std::io::ErrorKind; use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::OnceLock; use std::time::{Duration, Instant}; @@ -14,23 +16,41 @@ const POLL_INTERVAL: Duration = Duration::from_millis(25); enum LxcLsOutcome { ExitedSuccess, ExitedFailure, + NotFound, SpawnFailed, TimedOut, } +/// What execution should conclude from probing the LXC tooling. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LxcTooling { + Available, + /// `lxc-ls` is absent or reports itself broken. + Unavailable, + /// The probe itself failed (timeout, spawn error); worth retrying. + ProbeFailed, +} + /// Runs `lxc-ls --version`; only a clean exit counts as available. pub fn is_lxc_available() -> bool { static AVAILABLE: OnceLock = OnceLock::new(); *AVAILABLE.get_or_init(|| available_from(probe_lxc_ls())) } -/// [`is_lxc_available`] without the cached discovery answer. +/// The tooling verdict for execution, separate from the discovery cache. /// -/// Execution must not inherit an advisory verdict: a host that gained the LXC -/// tools after an earlier probe can run a sandbox now, and a cached `false` -/// would refuse it for the life of the process. -pub fn is_lxc_available_uncached() -> bool { - available_from(probe_lxc_ls()) +/// Only success is cached: a host that gains the LXC tools later, or whose +/// probe was transiently slow, is re-probed on the next run. +pub fn lxc_tooling_for_execution() -> LxcTooling { + static CONFIRMED: AtomicBool = AtomicBool::new(false); + if CONFIRMED.load(Ordering::Relaxed) { + return LxcTooling::Available; + } + let tooling = tooling_from(probe_lxc_ls()); + if tooling == LxcTooling::Available { + CONFIRMED.store(true, Ordering::Relaxed); + } + tooling } fn probe_lxc_ls() -> LxcLsOutcome { @@ -42,6 +62,7 @@ fn probe_lxc_ls() -> LxcLsOutcome { .spawn(); match child { Ok(mut child) => wait_bounded(&mut child, PROBE_TIMEOUT), + Err(error) if error.kind() == ErrorKind::NotFound => LxcLsOutcome::NotFound, Err(_) => LxcLsOutcome::SpawnFailed, } } @@ -69,6 +90,14 @@ fn available_from(outcome: LxcLsOutcome) -> bool { matches!(outcome, LxcLsOutcome::ExitedSuccess) } +fn tooling_from(outcome: LxcLsOutcome) -> LxcTooling { + match outcome { + LxcLsOutcome::ExitedSuccess => LxcTooling::Available, + LxcLsOutcome::NotFound | LxcLsOutcome::ExitedFailure => LxcTooling::Unavailable, + LxcLsOutcome::SpawnFailed | LxcLsOutcome::TimedOut => LxcTooling::ProbeFailed, + } +} + #[cfg(test)] mod tests { use super::*; @@ -77,7 +106,32 @@ mod tests { fn only_a_clean_exit_means_available() { assert!(available_from(LxcLsOutcome::ExitedSuccess)); assert!(!available_from(LxcLsOutcome::ExitedFailure)); + assert!(!available_from(LxcLsOutcome::NotFound)); assert!(!available_from(LxcLsOutcome::SpawnFailed)); assert!(!available_from(LxcLsOutcome::TimedOut)); } + + #[test] + fn a_missing_tool_is_unavailable_but_a_failed_probe_is_not() { + assert_eq!( + tooling_from(LxcLsOutcome::ExitedSuccess), + LxcTooling::Available + ); + assert_eq!( + tooling_from(LxcLsOutcome::NotFound), + LxcTooling::Unavailable + ); + assert_eq!( + tooling_from(LxcLsOutcome::ExitedFailure), + LxcTooling::Unavailable + ); + assert_eq!( + tooling_from(LxcLsOutcome::TimedOut), + LxcTooling::ProbeFailed + ); + assert_eq!( + tooling_from(LxcLsOutcome::SpawnFailed), + LxcTooling::ProbeFailed + ); + } } diff --git a/src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs b/src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs index 4ccca5caf..87a27730a 100644 --- a/src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs +++ b/src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs @@ -29,6 +29,7 @@ use crate::mxc_common::sandbox_process::{ #[cfg(target_os = "linux")] use crate::mxc_pty::{LivePty, PtySize as UnixPtySize}; +use crate::lxc_common::availability::LxcTooling; use crate::lxc_common::filesystem_mounts; use crate::lxc_common::lxc_bindings::{ContainerFirewall, LxcContainer, StartNetwork}; use crate::lxc_common::network_ingress::IngressManager; @@ -487,10 +488,18 @@ impl LxcScriptRunner { return Err(ScriptResponse::rejected(&msg)); } - // Probed per run rather than through the cached discovery answer: a - // host that gained the LXC tooling after an earlier probe is usable now. - if !crate::lxc_common::availability::is_lxc_available_uncached() { - return Err(ScriptResponse::unavailable(LXC_TOOLING_UNAVAILABLE)); + match crate::lxc_common::availability::lxc_tooling_for_execution() { + LxcTooling::Available => {} + LxcTooling::Unavailable => { + return Err(ScriptResponse::unavailable(LXC_TOOLING_UNAVAILABLE)); + } + LxcTooling::ProbeFailed => { + return Err(response_with_phase( + FailurePhase::LaunchFailed, + "LXC: probing the LXC tooling (`lxc-ls --version`) timed out or \ + could not run; retry once the host is responsive", + )); + } } if self.destroy_on_exit { diff --git a/src/mxc-sdk/src/backends/process_container/common/dispatcher.rs b/src/mxc-sdk/src/backends/process_container/common/dispatcher.rs index e14ccdd4d..c490b8ada 100644 --- a/src/mxc-sdk/src/backends/process_container/common/dispatcher.rs +++ b/src/mxc-sdk/src/backends/process_container/common/dispatcher.rs @@ -288,23 +288,25 @@ impl std::error::Error for DispatchError {} impl DispatchError { /// The wire code this dispatch failure is reported under. /// - /// A field the selected tier cannot represent is a refusal of the policy: - /// the message names the field, and a request that drops it runs. The rest - /// are the tier itself being unavailable -- none could be built, or a - /// required provider is absent -- so the policy was never judged, which is - /// the same distinction the [`RejectionReason::RunnerUnavailable`] audit - /// reason draws. A SID that could not be derived carries its own code, and - /// ACEs that could not be applied are a runtime fault. + /// A field the selected tier cannot represent, or an authored + /// `fallback.allowDaclMutation: false` the tier would have to override, is + /// a refusal of the policy: a changed request can run. The rest are the + /// tier itself being unavailable -- none could be built, or a required + /// provider is absent -- so the policy was never judged, which is the same + /// distinction the [`RejectionReason::RunnerUnavailable`] audit reason + /// draws. A SID that could not be derived carries its own code, and ACEs + /// that could not be applied are a runtime fault. /// /// [`RejectionReason::RunnerUnavailable`]: crate::mxc_common::audit::RejectionReason pub fn error_code(&self) -> MxcErrorCode { match self { DispatchError::Fallback( - FallbackError::EnumeratePathsUnsupported | FallbackError::IngressUnsupported, + FallbackError::EnumeratePathsUnsupported + | FallbackError::IngressUnsupported + | FallbackError::DaclFallbackDisabled, ) => MxcErrorCode::PolicyValidation, DispatchError::Fallback( - FallbackError::DaclFallbackDisabled - | FallbackError::WriteDacUnavailable { .. } + FallbackError::WriteDacUnavailable { .. } | FallbackError::SystemRootUnresolved { .. }, ) | DispatchError::CaptureDenialsUnsupported { .. } @@ -864,6 +866,7 @@ mod tests { for error in [ FallbackError::EnumeratePathsUnsupported, FallbackError::IngressUnsupported, + FallbackError::DaclFallbackDisabled, ] { let dispatch = DispatchError::Fallback(error); assert_eq!( @@ -880,7 +883,6 @@ mod tests { // No tier exists to judge the policy against, so reporting a rejection // would send the caller editing a config that was never the problem. for error in [ - FallbackError::DaclFallbackDisabled, FallbackError::WriteDacUnavailable { path: PathBuf::from("C:\\work"), reason: "access denied".to_string(), diff --git a/src/mxc-sdk/src/core/mxc_common/mxc_error.rs b/src/mxc-sdk/src/core/mxc_common/mxc_error.rs index cfa0bf3e6..928ee11c2 100644 --- a/src/mxc-sdk/src/core/mxc_common/mxc_error.rs +++ b/src/mxc-sdk/src/core/mxc_common/mxc_error.rs @@ -59,7 +59,16 @@ impl MxcErrorCode { pub fn exit_code(self) -> i32 { match self { Self::BackendUnavailable | Self::BackendError => -1, - _ => 1, + Self::MalformedRequest + | Self::UnsupportedContainment + | Self::UnsupportedPhase + | Self::MalformedId + | Self::StaleId + | Self::NotProvisioned + | Self::NotStarted + | Self::AlreadyStarted + | Self::AlreadyStopped + | Self::PolicyValidation => 1, } } } @@ -347,6 +356,27 @@ mod tests { } } + #[test] + fn only_host_and_runtime_faults_exit_minus_one() { + let cases = [ + (MxcErrorCode::MalformedRequest, 1), + (MxcErrorCode::UnsupportedContainment, 1), + (MxcErrorCode::UnsupportedPhase, 1), + (MxcErrorCode::BackendUnavailable, -1), + (MxcErrorCode::MalformedId, 1), + (MxcErrorCode::StaleId, 1), + (MxcErrorCode::NotProvisioned, 1), + (MxcErrorCode::NotStarted, 1), + (MxcErrorCode::AlreadyStarted, 1), + (MxcErrorCode::AlreadyStopped, 1), + (MxcErrorCode::PolicyValidation, 1), + (MxcErrorCode::BackendError, -1), + ]; + for (code, exit) in cases { + assert_eq!(code.exit_code(), exit, "{code}"); + } + } + #[test] fn convenience_constructors_set_correct_codes() { assert_eq!( diff --git a/src/mxc-sdk/src/core/mxc_common/script_runner.rs b/src/mxc-sdk/src/core/mxc_common/script_runner.rs index 8bfcf677b..b6b1fc8c2 100644 --- a/src/mxc-sdk/src/core/mxc_common/script_runner.rs +++ b/src/mxc-sdk/src/core/mxc_common/script_runner.rs @@ -102,8 +102,15 @@ pub fn handle_dry_run_exit(response: &ScriptResponse, logger: &mut Logger) -> ! /// as `backend_unavailable`, rather than both being flattened into the generic /// `backend_error` every other failure uses. pub fn emit_backend_error_envelope(response: &ScriptResponse) { + if let Some(line) = backend_error_envelope_line(response) { + eprintln!("{line}"); + } +} + +/// The stderr line [`emit_backend_error_envelope`] writes, if any. +fn backend_error_envelope_line(response: &ScriptResponse) -> Option { if response.exit_code == 0 || response.error_message.is_empty() { - return; + return None; } let mut envelope = serde_json::json!({ @@ -116,9 +123,7 @@ pub fn emit_backend_error_envelope(response: &ScriptResponse) { envelope["error"]["extended_error"] = serde_json::Value::String(response.extended_error.clone()); } - if let Ok(json) = serde_json::to_string(&envelope) { - eprintln!("{json}"); - } + serde_json::to_string(&envelope).ok() } /// The stderr lines a run's warnings are emitted as, one JSON object each. @@ -179,28 +184,49 @@ mod tests { fn error_envelope_is_noop_without_error() { use crate::mxc_common::models::ScriptResponse; // exit 0 => no-op; non-zero but empty message (clean sandbox exit) => no-op. - super::emit_backend_error_envelope(&ScriptResponse { + assert!(super::backend_error_envelope_line(&ScriptResponse { exit_code: 0, error_message: "ignored on success".to_string(), ..Default::default() - }); - super::emit_backend_error_envelope(&ScriptResponse { + }) + .is_none()); + assert!(super::backend_error_envelope_line(&ScriptResponse { exit_code: 1, error_message: String::new(), ..Default::default() - }); + }) + .is_none()); } #[test] - fn error_envelope_emits_on_infra_failure() { + fn error_envelope_carries_the_code_for_each_failure_kind() { use crate::mxc_common::models::ScriptResponse; - // Exercises the serialization branch (writes to stderr); must not panic. - super::emit_backend_error_envelope(&ScriptResponse { - exit_code: 1, - error_message: "backend unavailable".to_string(), + let cases = [ + (ScriptResponse::rejected("refused"), "policy_validation"), + ( + ScriptResponse::unavailable("missing"), + "backend_unavailable", + ), + (ScriptResponse::error("broke"), "backend_error"), + ]; + for (response, code) in cases { + let line = super::backend_error_envelope_line(&response).expect(code); + let parsed: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(parsed["error"]["code"], code, "{line}"); + assert_eq!(parsed["error"]["message"], response.error_message); + } + } + + #[test] + fn error_envelope_includes_extended_error_when_present() { + use crate::mxc_common::models::ScriptResponse; + let line = super::backend_error_envelope_line(&ScriptResponse { extended_error: "WIN32_ERROR(1920)".to_string(), - ..Default::default() - }); + ..ScriptResponse::unavailable("backend unavailable") + }) + .unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(parsed["error"]["extended_error"], "WIN32_ERROR(1920)"); } #[test] diff --git a/src/mxc-sdk/src/core/mxc_engine/dispatch.rs b/src/mxc-sdk/src/core/mxc_engine/dispatch.rs index 63815255b..f4445540b 100644 --- a/src/mxc-sdk/src/core/mxc_engine/dispatch.rs +++ b/src/mxc-sdk/src/core/mxc_engine/dispatch.rs @@ -297,7 +297,7 @@ fn spawn_process_container( match spawn_with_fallback(request, logger, StdioMode::Pipes, capture_factory) { Ok(dispatched) => { for w in &dispatched.warnings { - let _ = writeln!(logger, "warning: {w}"); + logger.warning_line(w); } let _ = writeln!( logger, @@ -307,12 +307,10 @@ fn spawn_process_container( Ok(dispatched.process) } Err(SpawnDispatchError::Dispatch(e)) => { - // Surface any retained-entry DACL warnings through the logger so the - // caller's buffer flush still reports them — mirroring the - // run-to-completion `resolve_runner_inner`. + // Retain DACL warnings so a failed dispatch still reports them. if let DispatchError::Dacl { warnings, .. } = &e { for w in warnings { - let _ = writeln!(logger, "dacl warning: {w}"); + logger.warning_line(w); } } Err(e.to_mxc_error()) @@ -328,7 +326,7 @@ fn spawn_process_container( // before its separate spawn attempt, so a spawn failure never loses // them there either. for w in &warnings { - let _ = writeln!(logger, "warning: {w}"); + logger.warning_line(w); } let _ = writeln!(logger, "selected isolation tier: {}", tier.as_str()); Err(map_spawn_error(*response)) diff --git a/src/mxc-sdk/src/core/mxc_engine/run.rs b/src/mxc-sdk/src/core/mxc_engine/run.rs index 3646e98ed..28510b61d 100644 --- a/src/mxc-sdk/src/core/mxc_engine/run.rs +++ b/src/mxc-sdk/src/core/mxc_engine/run.rs @@ -193,13 +193,24 @@ pub fn resolve_runner_for_audit( /// Convenience over [`resolve_runner`] for callers without external guard / /// signal machinery: it runs the resolved runner and drops it — then, on /// Windows, the DACL guard — in the correct order before returning the -/// [`ScriptResponse`]. +/// [`ScriptResponse`]. Warnings the logger recorded are moved onto the +/// response; nothing is written to the terminal. pub fn run(request: &ExecutionRequest, logger: &mut Logger) -> Result { - let mut resolved = resolve_runner(request, logger)?; - let response = resolved.runner.run(request, logger); - // `resolved` drops here: `runner` first (releasing child handles), then — - // on Windows — `dacl_manager` (restoring host ACEs). - Ok(response) + let resolved = resolve_runner(request, logger)?; + Ok(run_resolved(resolved, request, logger)) +} + +fn run_resolved( + mut resolved: ResolvedRunner, + request: &ExecutionRequest, + logger: &mut Logger, +) -> ScriptResponse { + let mut response = resolved.runner.run(request, logger); + // `runner` drops first (releasing child handles), then — on Windows — + // `dacl_manager` (restoring host ACEs). + drop(resolved); + response.extend_warnings(logger.take_warnings()); + response } // --------------------------------------------------------------------------- @@ -231,7 +242,7 @@ fn resolve_runner_inner_windows( ) { Ok(dispatched) => { for w in &dispatched.warnings { - let _ = writeln!(logger, "warning: {w}"); + logger.warning_line(w); } let _ = writeln!( logger, @@ -246,15 +257,14 @@ fn resolve_runner_inner_windows( }) } Err(e) => { - // Surface any retained-entry DACL warnings through the - // logger so the caller's buffer flush still reports them. + // Retain DACL warnings so a failed dispatch still reports them. if let crate::process_container_common::dispatcher::DispatchError::Dacl { warnings, .. } = &e { for w in warnings { - let _ = writeln!(logger, "dacl warning: {w}"); + logger.warning_line(w); } } Err(e.to_mxc_error()) @@ -311,11 +321,9 @@ fn resolve_runner_inner_windows( if ws.idle_timeout_ms != default.idle_timeout_ms || ws.daemon_pipe_name != default.daemon_pipe_name { - let _ = writeln!( - logger, - "warning: windowsSandbox.idleTimeoutMs and daemonPipeName \ - are ignored by the one-shot backend; each invocation launches and tears \ - down a fresh VM" + logger.warning_line( + "windowsSandbox.idleTimeoutMs and daemonPipeName are ignored by the \ + one-shot backend; each invocation launches and tears down a fresh VM", ); } } @@ -608,4 +616,67 @@ mod tests { assert!(!logger.get_buffer().contains("experimental")); } + + fn resolve_error(request: &ExecutionRequest) -> Error { + let mut logger = Logger::new(Mode::Buffer); + match resolve_runner(request, &mut logger) { + Ok(_) => panic!("{:?} must not resolve", request.containment), + Err(error) => error, + } + } + + #[test] + fn a_policy_no_tier_can_enforce_is_a_policy_validation_error() { + let _guard = crate::process_container_common::test_env::BcUsableGuard::set(false); + let mut request = ExecutionRequest { + containment: ContainmentBackend::ProcessContainer, + ..Default::default() + }; + request.policy.enumerate_paths = vec!["C:\\tools".to_string()]; + + let error = resolve_error(&request); + + assert_eq!(error.code, crate::ErrorCode::PolicyValidation, "{error}"); + } + + #[cfg(not(feature = "microvm"))] + #[test] + fn a_backend_not_compiled_in_is_unavailable() { + let request = ExecutionRequest { + containment: ContainmentBackend::MicroVm, + experimental_enabled: true, + ..Default::default() + }; + + let error = resolve_error(&request); + + assert_eq!(error.code, crate::ErrorCode::BackendUnavailable, "{error}"); + } +} + +#[cfg(test)] +mod run_tests { + use super::*; + use crate::mxc_common::logger::Mode; + + struct WarningRunner; + + impl ScriptRunner for WarningRunner { + fn execute(&mut self, _request: &ExecutionRequest, logger: &mut Logger) -> ScriptResponse { + logger.warning_line("advisory"); + ScriptResponse::default() + } + } + + #[test] + fn run_moves_logger_warnings_onto_the_response() { + let mut logger = Logger::new(Mode::Buffer); + let response = run_resolved( + ResolvedRunner::without_guard(Box::new(WarningRunner)), + &ExecutionRequest::default(), + &mut logger, + ); + assert_eq!(response.warnings(), ["advisory"]); + assert!(logger.warnings().is_empty()); + } } diff --git a/src/tools/lxc/src/main.rs b/src/tools/lxc/src/main.rs index 9be150311..53f50c13c 100644 --- a/src/tools/lxc/src/main.rs +++ b/src/tools/lxc/src/main.rs @@ -328,7 +328,7 @@ fn main() { let _ = writeln!(logger, "Runner completed in {}ms", run_elapsed.as_millis()); // Emitted before the dry-run branch below, which exits the process. - emit_warnings(&logger); + emit_warning_envelopes(response.warnings()); if cli.dry_run { handle_dry_run_exit(&response, &mut logger); diff --git a/src/tools/mxc_darwin/src/main.rs b/src/tools/mxc_darwin/src/main.rs index 64bbdb8bc..72a83979e 100644 --- a/src/tools/mxc_darwin/src/main.rs +++ b/src/tools/mxc_darwin/src/main.rs @@ -152,6 +152,7 @@ fn run_seatbelt(request: &ExecutionRequest, logger: &mut Logger) -> ! { let response = match mxc_sdk::mxc_engine::run(request, logger) { Ok(response) => response, Err(e) => { + emit_warning_envelopes(logger.warnings()); eprint!("{}", logger.get_buffer()); emit_mxc_error_exit(&e.into_mxc_error()); } @@ -159,6 +160,9 @@ fn run_seatbelt(request: &ExecutionRequest, logger: &mut Logger) -> ! { let run_elapsed = run_start.elapsed(); let _ = writeln!(logger, "Runner completed in {}ms", run_elapsed.as_millis()); + // Emitted before the dry-run branch below, which exits the process. + emit_warning_envelopes(response.warnings()); + if request.dry_run { handle_dry_run_exit(&response, logger); } diff --git a/src/tools/wxc/src/main.rs b/src/tools/wxc/src/main.rs index dd9a0cac5..8a3512cbb 100644 --- a/src/tools/wxc/src/main.rs +++ b/src/tools/wxc/src/main.rs @@ -18,7 +18,9 @@ use mxc_sdk::mxc_common::error::WxcError; use mxc_sdk::mxc_common::logger::{Logger, Mode}; use mxc_sdk::mxc_common::models::{ContainmentBackend, ExecutionRequest, ScriptResponse}; use mxc_sdk::mxc_common::mxc_error::{MxcError, MxcErrorCode}; -use mxc_sdk::mxc_common::script_runner::{emit_mxc_error_exit, handle_dry_run_exit, ScriptRunner}; +use mxc_sdk::mxc_common::script_runner::{ + emit_mxc_error_exit, emit_warning_envelopes, handle_dry_run_exit, ScriptRunner, +}; use mxc_sdk::mxc_common::state_aware_dispatch::{resolve_backend, DispatchOutcome}; use mxc_sdk::mxc_common::state_aware_request::{MxcRequest, ParsedStateAwareRequest, Phase}; use mxc_sdk::mxc_common::telemetry; @@ -349,7 +351,7 @@ fn logger_for_cli(cli: &Cli) -> Logger { if let Some(ref log_path) = cli.log_file { if let Err(error) = logger.enable_file_sink(std::path::Path::new(log_path)) { - eprintln!("Warning: could not open log file '{log_path}': {error}"); + emit_warning_envelopes(&[format!("could not open log file '{log_path}': {error}")]); } } @@ -1032,19 +1034,23 @@ fn main() { match mxc_sdk::mxc_common::filesystem_dacl::recover_orphaned_state() { Ok(report) => { if report.files_processed > 0 || !report.errors.is_empty() { - eprintln!( + let mut warnings = vec![format!( "DACL recovery: {} file(s), {} ACE(s) restored, {} pruned (missing), {} error(s)", report.files_processed, report.aces_restored, report.aces_pruned_missing, report.errors.len() + )]; + warnings.extend( + report + .errors + .iter() + .map(|e| format!("DACL recovery error: {e}")), ); - for e in &report.errors { - eprintln!(" {e}"); - } + emit_warning_envelopes(&warnings); } } - Err(e) => eprintln!("DACL recovery failed: {e}"), + Err(e) => emit_warning_envelopes(&[format!("DACL recovery failed: {e}")]), } // --probe is a detection-only fast path used by SDK @@ -1439,9 +1445,10 @@ fn main() { audit_context = Some(context); logger.log("WARNING: --audit enabled - AppContainer restrictions will NOT be enforced\n"); if injected && cli.audit_verbose { - eprintln!( - "[mxc] permissiveLearningMode injected via --audit - AppContainer restrictions are NOT enforced" - ); + emit_warning_envelopes(&[ + "permissiveLearningMode injected via --audit - AppContainer restrictions are NOT enforced" + .to_string(), + ]); } } @@ -1582,6 +1589,7 @@ fn main() { "", ); eprint!("{}", logger.get_buffer()); + emit_warning_envelopes(&logger.take_warnings()); telemetry::emit_early_exit_with_kind( telemetry_active, &request.containment, @@ -1658,7 +1666,7 @@ fn main() { // reads them from one structured place rather than scraping stderr. // Emitted before the dry-run branch below, which exits the process. response.extend_warnings(logger.take_warnings()); - mxc_sdk::mxc_common::script_runner::emit_warning_envelopes(response.warnings()); + emit_warning_envelopes(response.warnings()); #[cfg(target_os = "windows")] if let Err(error) = mxc_sdk::mxc_engine::emit_verbose_telemetry( diff --git a/tests/scripts/lib/WinProcessContainer.Common.ps1 b/tests/scripts/lib/WinProcessContainer.Common.ps1 index edfecefe2..16af2db60 100644 --- a/tests/scripts/lib/WinProcessContainer.Common.ps1 +++ b/tests/scripts/lib/WinProcessContainer.Common.ps1 @@ -946,10 +946,11 @@ function Test-WasRejected { if ($both -match '"reason"\s*:\s*"runner_unavailable"') { return $false } # Positive evidence. Every code below blames the request, so each one is a - # refusal; this mirrors the exit-1 set in MxcErrorCode::exit_code. The two - # text banners cover the paths that still print prose. Requiring one of - # these means an unexplained non-zero exit is NOT counted as a rejection. - $rejectionCodes = 'config_parse|policy_validation|malformed_request|' + + # refusal and exits 1 per MxcErrorCode::exit_code (lifecycle-state codes + # are omitted because one-shot runs cannot produce them). The two text + # banners cover the paths that still print prose. Requiring one of these + # means an unexplained non-zero exit is NOT counted as a rejection. + $rejectionCodes = 'policy_validation|malformed_request|' + 'unsupported_containment|unsupported_phase|malformed_id' return [bool]($both -match '(?im)^\s*Request error\s*$' -or $both -match '(?i)Configuration parse error' -or diff --git a/tests/scripts/lib/seatbelt_common.sh b/tests/scripts/lib/seatbelt_common.sh index 3e1a1d8f8..e50f503a3 100644 --- a/tests/scripts/lib/seatbelt_common.sh +++ b/tests/scripts/lib/seatbelt_common.sh @@ -189,6 +189,10 @@ expect_rejected() { fail "$label (the workload ran before the config was refused)" "$OUT" fi grep -qF "$marker" <<<"$OUT" || fail "$label (refused, but not for '$marker')" "$OUT" + # A refusal is a request fault: exit 1 with a request-side error code. + if [ "$RC" != 1 ] || ! grep -qE '"code":"(policy_validation|malformed_request)"' <<<"$OUT"; then + fail "$label (expected exit 1 with a policy_validation or malformed_request envelope, got exit $RC)" "$OUT" + fi pass "$label" } diff --git a/tests/scripts/run_bwrap_cwd_advisory_test.sh b/tests/scripts/run_bwrap_cwd_advisory_test.sh index 3b9204cd8..a5eab23dd 100644 --- a/tests/scripts/run_bwrap_cwd_advisory_test.sh +++ b/tests/scripts/run_bwrap_cwd_advisory_test.sh @@ -37,14 +37,21 @@ EOF for case in uncovered unrelated; do rc=0 output=$("$LXC_EXEC" --experimental "$WORK_DIR/$case.json" 2>"$WORK_DIR/$case.stderr") || rc=$? - # Matched piecewise rather than as one literal line: the advisory is carried - # in a JSON warning envelope, so the quotes around the path are escaped. + # One warning envelope must carry the whole advisory, path included. if [ "$rc" -eq 0 ] \ - || ! grep -Fq '{"warning":' "$WORK_DIR/$case.stderr" \ - || ! grep -Fq 'Bubblewrap: process.cwd' "$WORK_DIR/$case.stderr" \ - || ! grep -Fq "$CWD" "$WORK_DIR/$case.stderr" \ - || ! grep -Fq 'filesystem.readonlyPaths' "$WORK_DIR/$case.stderr" \ + || ! python3 - "$WORK_DIR/$case.stderr" "$CWD" <<'PY' \ || ! grep -Eq '(^|[[:space:]])bwrap:' "$WORK_DIR/$case.stderr"; then +import json, sys +path, cwd = sys.argv[1], sys.argv[2] +for line in open(path, encoding="utf-8"): + try: + message = json.loads(line)["warning"]["message"] + except (ValueError, KeyError, TypeError): + continue + if all(s in message for s in ("Bubblewrap: process.cwd", cwd, "filesystem.readonlyPaths")): + sys.exit(0) +sys.exit(1) +PY echo "$case cwd did not report the hint and Bubblewrap's original error: $output" cat "$WORK_DIR/$case.stderr" exit 1 diff --git a/tests/scripts/run_bwrap_directional_test.sh b/tests/scripts/run_bwrap_directional_test.sh index 2f3219162..c95a28967 100644 --- a/tests/scripts/run_bwrap_directional_test.sh +++ b/tests/scripts/run_bwrap_directional_test.sh @@ -70,6 +70,11 @@ run_rejected() { echo "FAIL: $label (refused, but not for the expected reason: '$marker')" exit 1 fi + if [ "$rc" != 1 ] || ! grep -qF '"code":"policy_validation"' <<<"$out"; then + echo "$out" + echo "FAIL: $label (expected exit 1 with a policy_validation envelope, got exit $rc)" + exit 1 + fi echo "PASS: $label" } diff --git a/tests/scripts/run_bwrap_version_gate_test.sh b/tests/scripts/run_bwrap_version_gate_test.sh index 4bd45b150..56a12b424 100644 --- a/tests/scripts/run_bwrap_version_gate_test.sh +++ b/tests/scripts/run_bwrap_version_gate_test.sh @@ -56,6 +56,10 @@ assert_gated() { echo "$out" fail "$label (refused, but not for the expected reason: '$marker')" fi + if [ "$rc" != 255 ] || ! grep -qF '"code":"backend_unavailable"' <<<"$out"; then + echo "$out" + fail "$label (expected exit 255 with a backend_unavailable envelope, got exit $rc)" + fi echo "PASS: $label" } diff --git a/tests/scripts/run_processcontainer_dacl_disabled_test.ps1 b/tests/scripts/run_processcontainer_dacl_disabled_test.ps1 index fdf6bf986..04e758abf 100644 --- a/tests/scripts/run_processcontainer_dacl_disabled_test.ps1 +++ b/tests/scripts/run_processcontainer_dacl_disabled_test.ps1 @@ -50,16 +50,13 @@ function Phase-DaclDisabled { $stateAfter = @(Get-NewStateFiles) # A bare `exit != 0` is satisfied by a launch failure or a harness timeout, - # which is the false-green pattern this suite exists to remove. This refusal - # is raised during tier selection, before any launch API, so wxc-exec logs a - # ConfigRejected event with reason `runner_unavailable` and exits -1. - # Test-WasRejected does not apply here: it deliberately treats - # `runner_unavailable` as a non-rejection, because it means the host could - # not build a tier and the policy was never judged. + # which is the false-green pattern this suite exists to remove. The authored + # allowDaclMutation=false is what forbids the tier, so this is a typed + # policy refusal raised during tier selection, before any launch API. $stderrOrLog = ($r.Stderr + "`n" + $logContent) - $typedRefusal = [bool]($stderrOrLog -match '"reason"\s*:\s*"runner_unavailable"') - $refused = (-not $r.TimedOut) -and ($r.ExitCode -eq -1) -and $typedRefusal - Record-Result -Phase 'P5' -Name 'dispatch refused before launch (typed runner_unavailable, exit -1)' ` + $typedRefusal = [bool]($stderrOrLog -match '"code"\s*:\s*"policy_validation"') + $refused = (-not $r.TimedOut) -and ($r.ExitCode -eq 1) -and $typedRefusal + Record-Result -Phase 'P5' -Name 'dispatch refused before launch (typed policy_validation, exit 1)' ` -Pass $refused ` -Detail "exit=$($r.ExitCode); timedOut=$($r.TimedOut); typedRefusal=$typedRefusal" Record-Result -Phase 'P5' -Name 'rw ACL untouched' -Pass ($aclBefore -eq $aclAfter) From 387df586bc4f8a33a003f942ec9b3aa232556b82 Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:53:39 -0700 Subject: [PATCH 10/17] Centralize phase-aware ScriptResponse construction Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1c677fc2-77c3-43b0-a06f-da11d11447c8 --- .../bubblewrap/common/bwrap_runner.rs | 31 +++------ .../src/backends/hyperlight/common/mod.rs | 8 +-- .../isolation_session/common/sandbox.rs | 5 +- .../src/backends/lxc/common/lxc_runner.rs | 38 ++++------ src/mxc-sdk/src/backends/nanvix/runner/mod.rs | 8 +-- .../common/appcontainer_runner.rs | 12 ++-- .../common/base_container_runner.rs | 69 +++++-------------- .../common/guarded_capture.rs | 14 ++-- .../seatbelt/common/seatbelt_runner.rs | 7 +- .../windows_sandbox/lifecycle/error.rs | 5 +- src/mxc-sdk/src/backends/wslc/common/error.rs | 29 ++------ src/mxc-sdk/src/core/mxc_common/models.rs | 29 +++++--- 12 files changed, 84 insertions(+), 171 deletions(-) diff --git a/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs b/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs index 80ec4248c..99f4c19ab 100644 --- a/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs +++ b/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs @@ -45,24 +45,12 @@ use crate::bwrap_common::{ const DNS_PORT: u16 = 53; -/// A runtime fault, tagged with the phase of the run it broke in. -/// -/// A refused request and an unusable host have their own constructors on -/// [`ScriptResponse`]; this covers the faults that stay `backend_error` and -/// only say *where* the run came apart. -fn response_with_phase(phase: FailurePhase, message: &str) -> ScriptResponse { - ScriptResponse { - failure_phase: phase, - ..ScriptResponse::error(message) - } -} - /// A host lookup failure is a launch fault, not a refusal of the policy. fn proxy_resolve_failure_response(error: proxy_network::ProxyResolveError) -> ScriptResponse { match error { proxy_network::ProxyResolveError::Rejected(message) => ScriptResponse::rejected(&message), proxy_network::ProxyResolveError::Lookup(message) => { - response_with_phase(FailurePhase::LaunchFailed, &message) + ScriptResponse::failed(FailurePhase::LaunchFailed, &message) } } } @@ -508,7 +496,7 @@ impl BubblewrapScriptRunner { ) { Ok(network) => Some(network), Err(error) => { - return Err(response_with_phase(FailurePhase::LaunchFailed, &error)) + return Err(ScriptResponse::failed(FailurePhase::LaunchFailed, &error)) } } } @@ -539,7 +527,7 @@ impl BubblewrapScriptRunner { Some(network) } Err(error) => { - return Err(response_with_phase(FailurePhase::LaunchFailed, &error)) + return Err(ScriptResponse::failed(FailurePhase::LaunchFailed, &error)) } } } @@ -560,7 +548,7 @@ impl BubblewrapScriptRunner { Ok(startup) => Some(startup), Err(error) => { stop_proxy_network(&mut proxy_network, logger); - return Err(response_with_phase(FailurePhase::LaunchFailed, &error)); + return Err(ScriptResponse::failed(FailurePhase::LaunchFailed, &error)); } }, None => None, @@ -604,7 +592,7 @@ impl BubblewrapScriptRunner { Ok(pty) => Some(pty), Err(error) => { stop_proxy_network(&mut proxy_network, logger); - return Err(response_with_phase( + return Err(ScriptResponse::failed( FailurePhase::LaunchFailed, &format!("Bubblewrap: failed to allocate PTY: {error}"), )); @@ -627,7 +615,7 @@ impl BubblewrapScriptRunner { Ok(process) => process, Err(error) => { stop_proxy_network(&mut proxy_network, logger); - return Err(response_with_phase( + return Err(ScriptResponse::failed( FailurePhase::LaunchFailed, &format!("Bubblewrap: failed to spawn bwrap: {}", error), )); @@ -653,7 +641,10 @@ impl BubblewrapScriptRunner { let _ = child.kill(); let _ = child.wait(); stop_proxy_network(&mut proxy_network, logger); - return Err(response_with_phase(FailurePhase::PostLaunchFailed, &error)); + return Err(ScriptResponse::failed( + FailurePhase::PostLaunchFailed, + &error, + )); } } @@ -676,7 +667,7 @@ impl BubblewrapScriptRunner { let _ = child.wait(); stop_proxy_network(&mut proxy_network, logger); let error = out_result.err().or(err_result.err()); - return Err(response_with_phase( + return Err(ScriptResponse::failed( FailurePhase::PostLaunchFailed, &format!( "Bubblewrap: failed to wrap stdio pipes: {}", diff --git a/src/mxc-sdk/src/backends/hyperlight/common/mod.rs b/src/mxc-sdk/src/backends/hyperlight/common/mod.rs index c41cc2a57..ceccb6ecd 100644 --- a/src/mxc-sdk/src/backends/hyperlight/common/mod.rs +++ b/src/mxc-sdk/src/backends/hyperlight/common/mod.rs @@ -184,13 +184,7 @@ impl RunnerError { } fn to_response(&self) -> ScriptResponse { - let failure_phase = self.failure_phase(); - ScriptResponse { - exit_code: failure_phase.failure_exit_code(), - error_message: self.to_string(), - failure_phase, - ..Default::default() - } + ScriptResponse::failed(self.failure_phase(), &self.to_string()) } } diff --git a/src/mxc-sdk/src/backends/isolation_session/common/sandbox.rs b/src/mxc-sdk/src/backends/isolation_session/common/sandbox.rs index 9c2451e0b..56de32eec 100644 --- a/src/mxc-sdk/src/backends/isolation_session/common/sandbox.rs +++ b/src/mxc-sdk/src/backends/isolation_session/common/sandbox.rs @@ -92,10 +92,7 @@ fn launch_failure_response(error: &MxcError) -> ScriptResponse { match error.code { MxcErrorCode::BackendUnavailable => ScriptResponse::unavailable(&error.message), MxcErrorCode::PolicyValidation => ScriptResponse::rejected(&error.message), - _ => ScriptResponse { - failure_phase: FailurePhase::LaunchFailed, - ..ScriptResponse::error(&error.message) - }, + _ => ScriptResponse::failed(FailurePhase::LaunchFailed, &error.message), } } diff --git a/src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs b/src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs index 87a27730a..35aeb2ce3 100644 --- a/src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs +++ b/src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs @@ -40,18 +40,6 @@ use crate::lxc_common::signal_cleanup; const HOSTS_PIN_MARKER: &str = "#mxc-proxy-pin"; -/// A runtime fault, tagged with the phase of the run it broke in. -/// -/// A refused request and an unusable host have their own constructors on -/// [`ScriptResponse`]; this covers the faults that stay `backend_error` and -/// only say *where* the run came apart. -fn response_with_phase(phase: FailurePhase, message: &str) -> ScriptResponse { - ScriptResponse { - failure_phase: phase, - ..ScriptResponse::error(message) - } -} - /// `PATH` for the contained child, from schema 0.9. /// /// The script runs with `lxc-attach` in clear-env mode, which supplies a small @@ -214,7 +202,7 @@ impl LxcScriptRunner { return self.claim_generated_name(); } ContainerNameClaim::acquire(&self.container_id).ok_or_else(|| { - response_with_phase( + ScriptResponse::failed( FailurePhase::LaunchFailed, &format!( "LXC: container '{}' is already in use by a run in this process. LXC reads a \ @@ -235,7 +223,7 @@ impl LxcScriptRunner { return Ok(claim); } } - Err(response_with_phase( + Err(ScriptResponse::failed( FailurePhase::LaunchFailed, "LXC: could not find a free container name for this run; every generated name was \ already in use by another run in this process. Retry, or set containerId to a name \ @@ -407,7 +395,7 @@ impl LxcScriptRunner { let release = self.release_kind(container_created); Self::report_release_failure(release, release_container(release), logger); - Some(response_with_phase( + Some(ScriptResponse::failed( FailurePhase::PostLaunchFailed, &format!( "Container did not receive an IPv4 address within {:.0}s; \ @@ -437,7 +425,7 @@ impl LxcScriptRunner { let release = self.release_kind(container_created); Self::report_release_failure(release, release_container(release), logger); - Err(response_with_phase( + Err(ScriptResponse::failed( FailurePhase::PostLaunchFailed, "Failed to discover the container init PID; cannot enter the container \ network namespace to enforce the requested firewall. Aborting rather \ @@ -494,7 +482,7 @@ impl LxcScriptRunner { return Err(ScriptResponse::unavailable(LXC_TOOLING_UNAVAILABLE)); } LxcTooling::ProbeFailed => { - return Err(response_with_phase( + return Err(ScriptResponse::failed( FailurePhase::LaunchFailed, "LXC: probing the LXC tooling (`lxc-ls --version`) timed out or \ could not run; retry once the host is responsive", @@ -528,7 +516,7 @@ impl LxcScriptRunner { if !container.is_defined() { let _ = writeln!(logger, "Creating LXC container..."); if let Err(e) = container.create(&self.config.distribution, &self.config.release) { - return Err(response_with_phase( + return Err(ScriptResponse::failed( FailurePhase::LaunchFailed, &format!("Failed to create container: {}", e), )); @@ -545,7 +533,7 @@ impl LxcScriptRunner { if self.destroy_on_exit || container_created { let _ = container.destroy(); } - return Err(response_with_phase( + return Err(ScriptResponse::failed( FailurePhase::LaunchFailed, &format!("Failed to configure filesystem: {}", e), )); @@ -562,7 +550,7 @@ impl LxcScriptRunner { if self.destroy_on_exit || container_created { let _ = container.destroy(); } - return Err(response_with_phase( + return Err(ScriptResponse::failed( FailurePhase::LaunchFailed, &format!( "Failed to stop a container left running by an earlier run: {}. \ @@ -590,7 +578,7 @@ impl LxcScriptRunner { if self.destroy_on_exit || container_created { let _ = container.destroy(); } - return Err(response_with_phase( + return Err(ScriptResponse::failed( FailurePhase::LaunchFailed, &format!("Failed to start container: {}", e), )); @@ -650,7 +638,7 @@ impl LxcScriptRunner { Ok(managers) => managers, Err(e) => { self.release_after_failure(&container, container_created, logger); - return Err(response_with_phase(FailurePhase::PostLaunchFailed, &e)); + return Err(ScriptResponse::failed(FailurePhase::PostLaunchFailed, &e)); } }; @@ -676,7 +664,7 @@ impl LxcScriptRunner { }); if let Err(reason) = stale_pin_error { self.release_after_failure(&container, container_created, logger); - return Err(response_with_phase( + return Err(ScriptResponse::failed( FailurePhase::PostLaunchFailed, &format!( "Failed to clear a stale network proxy pin from the container's \ @@ -1142,7 +1130,7 @@ impl LxcScriptRunner { Ok(launched) => launched, Err(e) => { let cleanup = self.abandon_prepared(&mut prepared, logger); - return Err(response_with_phase( + return Err(ScriptResponse::failed( FailurePhase::LaunchFailed, &launch_failure_message(&format!("Execution failed: {}", e), &cleanup), )); @@ -1172,7 +1160,7 @@ impl LxcScriptRunner { let cleanup = self.abandon_prepared(&mut prepared, logger); let error = out_result.err().or(err_result.err()); - return Err(response_with_phase( + return Err(ScriptResponse::failed( FailurePhase::PostLaunchFailed, &launch_failure_message( &format!( diff --git a/src/mxc-sdk/src/backends/nanvix/runner/mod.rs b/src/mxc-sdk/src/backends/nanvix/runner/mod.rs index bf9b40a2b..950f06aaf 100644 --- a/src/mxc-sdk/src/backends/nanvix/runner/mod.rs +++ b/src/mxc-sdk/src/backends/nanvix/runner/mod.rs @@ -187,13 +187,7 @@ impl NanVixError { } fn to_response(&self) -> ScriptResponse { - let failure_phase = self.failure_phase(); - ScriptResponse { - exit_code: failure_phase.failure_exit_code(), - error_message: self.to_string(), - failure_phase, - ..Default::default() - } + ScriptResponse::failed(self.failure_phase(), &self.to_string()) } } diff --git a/src/mxc-sdk/src/backends/process_container/common/appcontainer_runner.rs b/src/mxc-sdk/src/backends/process_container/common/appcontainer_runner.rs index 47d7fafd3..88acd0936 100644 --- a/src/mxc-sdk/src/backends/process_container/common/appcontainer_runner.rs +++ b/src/mxc-sdk/src/backends/process_container/common/appcontainer_runner.rs @@ -93,10 +93,7 @@ const BFSCFG_MISSING_MSG: &str = /// A failure while bringing the sandbox up, before the child could run. fn launch_failed(message: &str) -> ScriptResponse { - ScriptResponse { - failure_phase: FailurePhase::LaunchFailed, - ..ScriptResponse::error(message) - } + ScriptResponse::failed(FailurePhase::LaunchFailed, message) } /// Attribute a BFS configuration failure: an absent `bfscfg.exe` is a host that @@ -1795,10 +1792,9 @@ impl SandboxBackend for AppContainerScriptRunner { false, )?; if request.policy.capture_denials.is_some() && self.guarded_capture_factory.is_none() { - return Err(ScriptResponse { - failure_phase: FailurePhase::BackendUnavailable, - ..ScriptResponse::error(CAPTURE_DENIALS_FALLBACK_UNSUPPORTED_MSG) - }); + return Err(ScriptResponse::unavailable( + CAPTURE_DENIALS_FALLBACK_UNSUPPORTED_MSG, + )); } if !request.policy.denied_paths.is_empty() && self.filesystem_mode != FilesystemMode::Dacl diff --git a/src/mxc-sdk/src/backends/process_container/common/base_container_runner.rs b/src/mxc-sdk/src/backends/process_container/common/base_container_runner.rs index 05376145e..674c53270 100644 --- a/src/mxc-sdk/src/backends/process_container/common/base_container_runner.rs +++ b/src/mxc-sdk/src/backends/process_container/common/base_container_runner.rs @@ -564,22 +564,19 @@ impl BaseContainerRunner { let proxy_loopback_workaround = Self::psec_1_0_proxy_loopback_workaround(request, secenv::supports_version).map_err( - |error| ScriptResponse { - failure_phase: FailurePhase::BackendUnavailable, - ..ScriptResponse::error(&format!( + |error| { + ScriptResponse::unavailable(&format!( "failed to query PSEC proxy compatibility support: {error}" )) }, )?; let psec_version = Self::choose_min_required_psec_version_for_request(request, proxy_loopback_workaround); - let version_supported = - secenv::supports_version(psec_version).map_err(|error| ScriptResponse { - failure_phase: FailurePhase::BackendUnavailable, - ..ScriptResponse::error(&format!( - "failed to query Process Security Environment contract support: {error}" - )) - })?; + let version_supported = secenv::supports_version(psec_version).map_err(|error| { + ScriptResponse::unavailable(&format!( + "failed to query Process Security Environment contract support: {error}" + )) + })?; if !version_supported { return Err(Self::unsupported_psec_version_response( request, @@ -869,13 +866,7 @@ impl BaseContainerRunner { FailurePhase::LaunchFailed }; self.cleanup_capture_begin_failure(logger); - return Err(ScriptResponse { - exit_code: -1, - error_message: msg.clone(), - standard_err: msg, - failure_phase, - ..Default::default() - }); + return Err(ScriptResponse::failed(failure_phase, &msg)); } } } else { @@ -903,13 +894,7 @@ impl BaseContainerRunner { } else { FailurePhase::LaunchFailed }; - return Err(ScriptResponse { - exit_code: -1, - error_message: msg.clone(), - standard_err: msg, - failure_phase, - ..Default::default() - }); + return Err(ScriptResponse::failed(failure_phase, &msg)); } } } @@ -965,13 +950,7 @@ impl BaseContainerRunner { if capture_denials.is_some() { self.cleanup_capture_begin_failure(logger); } - return Err(ScriptResponse { - exit_code: -1, - error_message: msg.clone(), - standard_err: msg, - failure_phase, - ..Default::default() - }); + return Err(ScriptResponse::failed(failure_phase, &msg)); } }; let environment = (!current_env_ptr.is_null()).then_some(current_env_ptr); @@ -1056,12 +1035,8 @@ impl BaseContainerRunner { }; return Err(ScriptResponse { - exit_code: -1, - error_message: diag.message.clone(), - standard_err: diag.message, extended_error, - failure_phase, - ..Default::default() + ..ScriptResponse::failed(failure_phase, &diag.message) }); } @@ -1137,12 +1112,8 @@ impl BaseContainerRunner { ); } return Err(ScriptResponse { - exit_code: -1, - error_message: JOB_SETUP_FAILED_MSG.to_string(), - standard_err: JOB_SETUP_FAILED_MSG.to_string(), extended_error, - failure_phase: FailurePhase::LaunchFailed, - ..Default::default() + ..ScriptResponse::failed(FailurePhase::LaunchFailed, JOB_SETUP_FAILED_MSG) }); } }; @@ -1177,10 +1148,7 @@ impl BaseContainerRunner { ); } self.proxy_coordinator.stop(logger); - return Err(ScriptResponse { - failure_phase: FailurePhase::LaunchFailed, - ..ScriptResponse::error(&message) - }); + return Err(ScriptResponse::failed(FailurePhase::LaunchFailed, &message)); } crate::mxc_common::telemetry::log_network_policy_applied( @@ -1286,13 +1254,10 @@ impl SandboxBackend for BaseContainerRunner { if !self.request_serviceability_confirmed && !Self::can_backend_service_request(request).can_service_request() { - return Err(ScriptResponse { - failure_phase: FailurePhase::BackendUnavailable, - ..ScriptResponse::error( - "the request cannot be represented by the process security environment \ - available on this host", - ) - }); + return Err(ScriptResponse::unavailable( + "the request cannot be represented by the process security environment \ + available on this host", + )); } if request.policy.least_privilege_mode { return Err(ScriptResponse::rejected( diff --git a/src/mxc-sdk/src/backends/process_container/common/guarded_capture.rs b/src/mxc-sdk/src/backends/process_container/common/guarded_capture.rs index 37cc119cb..299d1ac73 100644 --- a/src/mxc-sdk/src/backends/process_container/common/guarded_capture.rs +++ b/src/mxc-sdk/src/backends/process_container/common/guarded_capture.rs @@ -25,9 +25,7 @@ use std::path::Path; use crate::learning_mode_core::AnalysisResult; -use crate::mxc_common::models::{ - CaptureDenialsErrorOutput, FailurePhase, SandboxOutputMetadata, ScriptResponse, -}; +use crate::mxc_common::models::{CaptureDenialsErrorOutput, SandboxOutputMetadata, ScriptResponse}; /// Error returned when an injected guarded-capture implementation cannot /// transfer retained ETL requested by the caller. @@ -170,10 +168,7 @@ pub fn validate_retain_etl_supported( native_capture_retains_etl: bool, ) -> Result<(), ScriptResponse> { if retain_etl && !native_capture_retains_etl && !provider_allows_trace_transfer { - return Err(ScriptResponse { - failure_phase: FailurePhase::BackendUnavailable, - ..ScriptResponse::error(RETAIN_ETL_UNSUPPORTED_MSG) - }); + return Err(ScriptResponse::unavailable(RETAIN_ETL_UNSUPPORTED_MSG)); } Ok(()) } @@ -700,7 +695,10 @@ mod tests { #[test] fn retain_etl_validation_rejects_when_no_transfer_and_no_native() { let error = validate_retain_etl_supported(true, false, false).unwrap_err(); - assert_eq!(error.failure_phase, FailurePhase::BackendUnavailable); + assert_eq!( + error.failure_phase, + crate::mxc_common::models::FailurePhase::BackendUnavailable + ); assert_eq!(error.error_message, RETAIN_ETL_UNSUPPORTED_MSG); } diff --git a/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs b/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs index a7b50efa0..d7ebe7990 100644 --- a/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs +++ b/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs @@ -1094,12 +1094,7 @@ fn rejected_response(message: String) -> ScriptResponse { /// A fault while bringing the sandboxed child up, after the request was /// accepted. fn launch_failed_response(message: String) -> ScriptResponse { - ScriptResponse { - exit_code: FailurePhase::LaunchFailed.failure_exit_code(), - error_message: message, - failure_phase: FailurePhase::LaunchFailed, - ..Default::default() - } + ScriptResponse::failed(FailurePhase::LaunchFailed, &message) } /// The optional run timeout — `None` when `scriptTimeout` is 0 (wait forever). diff --git a/src/mxc-sdk/src/backends/windows_sandbox/lifecycle/error.rs b/src/mxc-sdk/src/backends/windows_sandbox/lifecycle/error.rs index abba99327..227888e09 100644 --- a/src/mxc-sdk/src/backends/windows_sandbox/lifecycle/error.rs +++ b/src/mxc-sdk/src/backends/windows_sandbox/lifecycle/error.rs @@ -59,12 +59,9 @@ impl OneShotError { }; ScriptResponse { - exit_code: failure_phase.failure_exit_code(), standard_err: summary.to_string(), - error_message: summary.to_string(), extended_error: detail, - failure_phase, - ..Default::default() + ..ScriptResponse::failed(failure_phase, summary) } } diff --git a/src/mxc-sdk/src/backends/wslc/common/error.rs b/src/mxc-sdk/src/backends/wslc/common/error.rs index 8e187947c..4e284b676 100644 --- a/src/mxc-sdk/src/backends/wslc/common/error.rs +++ b/src/mxc-sdk/src/backends/wslc/common/error.rs @@ -11,10 +11,8 @@ //! the message. Each variant here attributes the failure to a lifecycle phase //! instead. //! -//! `Display` reproduces the pre-existing message verbatim and -//! [`WslcError::into_response`] fills the same fields `ScriptResponse::error` -//! did, so the text a user sees is unchanged; `failure_phase` is the only -//! addition. +//! `Display` reproduces the pre-existing message verbatim, so the text a user +//! sees is unchanged. use std::fmt; @@ -68,18 +66,9 @@ impl WslcError { } } - /// Render as a [`ScriptResponse`], preserving the exact message text the - /// untyped construction produced. + /// Render as a [`ScriptResponse`] carrying the message verbatim. pub(crate) fn into_response(self) -> ScriptResponse { - let failure_phase = self.failure_phase(); - let msg = self.to_string(); - ScriptResponse { - exit_code: failure_phase.failure_exit_code(), - standard_err: msg.clone(), - error_message: msg, - failure_phase, - ..Default::default() - } + ScriptResponse::failed(self.failure_phase(), &self.to_string()) } } @@ -161,15 +150,11 @@ mod tests { } #[test] - fn response_matches_the_untyped_shape_apart_from_the_phase() { + fn response_carries_the_message_and_the_phase() { let typed = WslcError::Host("bring-up failed".to_string()).into_response(); - let untyped = ScriptResponse::error("bring-up failed"); - assert_eq!(typed.exit_code, untyped.exit_code); - assert_eq!(typed.standard_err, untyped.standard_err); - assert_eq!(typed.error_message, untyped.error_message); - // The untyped helper left this at the default, which is the bug. - assert_eq!(untyped.failure_phase, FailurePhase::None); + assert_eq!(typed.exit_code, -1); + assert_eq!(typed.error_message, "bring-up failed"); assert_eq!(typed.failure_phase, FailurePhase::LaunchFailed); } diff --git a/src/mxc-sdk/src/core/mxc_common/models.rs b/src/mxc-sdk/src/core/mxc_common/models.rs index ef491e57d..b6a638ab8 100644 --- a/src/mxc-sdk/src/core/mxc_common/models.rs +++ b/src/mxc-sdk/src/core/mxc_common/models.rs @@ -1319,12 +1319,7 @@ impl ScriptResponse { /// copying it would print the message bare and then again inside the JSON /// envelope. pub fn rejected(msg: &str) -> Self { - ScriptResponse { - exit_code: FailurePhase::Rejected.failure_exit_code(), - error_message: msg.to_string(), - failure_phase: FailurePhase::Rejected, - ..Default::default() - } + Self::failed(FailurePhase::Rejected, msg) } /// Create a response for a backend this host cannot provide. @@ -1334,10 +1329,18 @@ impl ScriptResponse { /// [`ScriptResponse::rejected`] nothing ran, so the message is left to the /// envelope rather than copied into `standard_err`. pub fn unavailable(msg: &str) -> Self { + Self::failed(FailurePhase::BackendUnavailable, msg) + } + + /// Create a response for a failure attributed to `phase`. + /// + /// The exit code follows the phase, and the message is left to the error + /// envelope rather than copied into `standard_err`. + pub fn failed(phase: FailurePhase, msg: &str) -> Self { ScriptResponse { - exit_code: FailurePhase::BackendUnavailable.failure_exit_code(), + exit_code: phase.failure_exit_code(), error_message: msg.to_string(), - failure_phase: FailurePhase::BackendUnavailable, + failure_phase: phase, ..Default::default() } } @@ -1457,6 +1460,16 @@ mod tests { assert!(rejected.standard_err.is_empty()); } + #[test] + fn a_phased_failure_takes_its_exit_code_from_the_phase() { + let launch = ScriptResponse::failed(FailurePhase::LaunchFailed, "spawn failed"); + + assert_eq!(launch.exit_code, -1); + assert_eq!(launch.failure_phase, FailurePhase::LaunchFailed); + assert_eq!(launch.error_message, "spawn failed"); + assert!(launch.standard_err.is_empty()); + } + #[test] fn sandbox_paths_normalize_against_the_root() { for (input, expected) in [ From 526781781d9be023a771b820f3a78412f19778cc Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:02:26 -0700 Subject: [PATCH 11/17] Fix engine warning tests failing on Windows and macOS Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1c677fc2-77c3-43b0-a06f-da11d11447c8 --- src/mxc-sdk/src/core/mxc_engine/run.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/mxc-sdk/src/core/mxc_engine/run.rs b/src/mxc-sdk/src/core/mxc_engine/run.rs index 28510b61d..74a92ba11 100644 --- a/src/mxc-sdk/src/core/mxc_engine/run.rs +++ b/src/mxc-sdk/src/core/mxc_engine/run.rs @@ -582,6 +582,7 @@ mod tests { let mut logger = Logger::new(Mode::Buffer); resolve_runner_inner_windows(&request, &mut logger).unwrap(); assert!(logger.get_buffer().is_empty()); + assert!(logger.warnings().is_empty()); } } @@ -596,7 +597,7 @@ mod tests { resolve_runner_inner_windows(&request, &mut logger).unwrap(); - let warning = logger.get_buffer(); + let warning = logger.warnings().join("\n"); assert!(warning.contains("idleTimeoutMs")); assert!(warning.contains("daemonPipeName")); assert!(warning.contains("fresh VM")); @@ -673,7 +674,10 @@ mod run_tests { let mut logger = Logger::new(Mode::Buffer); let response = run_resolved( ResolvedRunner::without_guard(Box::new(WarningRunner)), - &ExecutionRequest::default(), + &ExecutionRequest { + script_code: "echo hi".to_string(), + ..Default::default() + }, &mut logger, ); assert_eq!(response.warnings(), ["advisory"]); From bc0184723ca142c29da6693614867fe998279510 Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:10:55 -0700 Subject: [PATCH 12/17] Address Copilot review: timeout phase, warning line start, lifecycle exit codes - IsolationSession one-shot reports a wait timeout as FailurePhase::Timeout instead of ProcessExited. - Warning envelopes start on a fresh stderr line. - Lifecycle error envelopes exit with their code's status instead of always 1. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1c677fc2-77c3-43b0-a06f-da11d11447c8 --- .../isolation_session/common/manager.rs | 11 ++-- .../isolation_session/common/one_shot.rs | 50 +++++++++++-------- .../isolation_session/common/state_aware.rs | 12 +++-- .../src/core/mxc_common/script_runner.rs | 8 +-- src/tools/wxc/src/main.rs | 29 ++++++----- 5 files changed, 64 insertions(+), 46 deletions(-) diff --git a/src/mxc-sdk/src/backends/isolation_session/common/manager.rs b/src/mxc-sdk/src/backends/isolation_session/common/manager.rs index 7ec4c8b79..0a18d5ade 100644 --- a/src/mxc-sdk/src/backends/isolation_session/common/manager.rs +++ b/src/mxc-sdk/src/backends/isolation_session/common/manager.rs @@ -398,7 +398,7 @@ impl IsolationSessionManager { /// Step 3: Create a process inside the started isolation session and run it /// to completion. Output is streamed live to the calling process's stdio via - /// internal relay threads; only the exit code is returned. + /// internal relay threads; only the exit code or timeout is returned. /// /// This is the **relay** path, reached by `wxc-exec` and by an in-process /// caller that asked to attach. A caller that wants the streams handed back @@ -407,7 +407,7 @@ impl IsolationSessionManager { &self, options: &ProcessOptions, logger: Option<&mut Logger>, - ) -> Result { + ) -> Result { owned_thread::call(&self.impersonation, || { // Everything fallible that does not need the workload runs first, so no // failure can strand a running one. @@ -583,10 +583,7 @@ impl IsolationSessionManager { scope.stop_stdin(); scope.finish(); - Ok(match outcome { - ExecOutcome::Exited(exit_code) => exit_code, - ExecOutcome::TimedOut => WAIT_FOR_EXIT_TIMEOUT, - }) + Ok(outcome) }) } @@ -1219,7 +1216,7 @@ const STILL_ACTIVE: i32 = windows::Win32::Foundation::STATUS_PENDING.0; /// What `WaitForExit` returns when the deadline elapses before the process /// exits. Like [`STILL_ACTIVE`] this is a legal exit code in its own right, so /// it is never used alone to conclude a timeout. -const WAIT_FOR_EXIT_TIMEOUT: i32 = -1; +pub(super) const WAIT_FOR_EXIT_TIMEOUT: i32 = -1; /// How long [`StartedProcess::terminate`] waits for a kill to land before /// reporting success anyway. Bounded so a failed `Terminate` cannot wedge that diff --git a/src/mxc-sdk/src/backends/isolation_session/common/one_shot.rs b/src/mxc-sdk/src/backends/isolation_session/common/one_shot.rs index cddb08086..89d523606 100644 --- a/src/mxc-sdk/src/backends/isolation_session/common/one_shot.rs +++ b/src/mxc-sdk/src/backends/isolation_session/common/one_shot.rs @@ -11,6 +11,7 @@ use std::io::IsTerminal; use crate::mxc_common::logger::Logger; use crate::mxc_common::models::{ExecutionRequest, FailurePhase, ScriptResponse}; use crate::mxc_common::script_runner::ScriptRunner; +use crate::mxc_common::state_aware_backend::ExecOutcome; use crate::mxc_common::validator::{validate_network_policy_support, NetworkPolicySupport}; use super::manager::{log_sandbox_torn_down, IsolationSessionManager, TeardownOutcome}; @@ -121,8 +122,8 @@ impl ScriptRunner for IsolationSessionRunner { return e.into(); } - let exit_code = match manager.create_process(&options, Some(logger)) { - Ok(code) => code, + let outcome = match manager.create_process(&options, Some(logger)) { + Ok(outcome) => outcome, Err(e) => { let stopped = manager.stop_session().is_ok(); let deprovisioned = manager.deprovision_agent_user().is_ok(); @@ -158,24 +159,24 @@ impl ScriptRunner for IsolationSessionRunner { // Output already streamed live to wxc-exec's stdio via relay // threads in `create_process` — captured fields intentionally // empty (same pattern as AppContainer). - ScriptResponse { - exit_code, - standard_out: String::new(), - standard_err: String::new(), - error_message: String::new(), - failure_phase: exited_phase(exit_code), - ..Default::default() - } + completed_response(outcome, options.timeout_ms) } } -/// The phase a completed run reports, so a workload that failed on its own is +/// The response for a completed run, so a workload that failed or timed out is /// told apart from the sandbox failing around it. -fn exited_phase(exit_code: i32) -> FailurePhase { - if exit_code == 0 { - FailurePhase::None - } else { - FailurePhase::ProcessExited +fn completed_response(outcome: ExecOutcome, timeout_ms: u32) -> ScriptResponse { + match outcome { + ExecOutcome::Exited(0) => ScriptResponse::default(), + ExecOutcome::Exited(exit_code) => ScriptResponse { + exit_code, + failure_phase: FailurePhase::ProcessExited, + ..Default::default() + }, + ExecOutcome::TimedOut => ScriptResponse::failed( + FailurePhase::Timeout, + &format!("script timed out after {timeout_ms}ms"), + ), } } @@ -187,10 +188,19 @@ mod tests { }; #[test] - fn a_nonzero_workload_exit_is_reported_as_a_process_exit() { - assert_eq!(exited_phase(0), FailurePhase::None); - assert_eq!(exited_phase(3), FailurePhase::ProcessExited); - assert_eq!(exited_phase(-1), FailurePhase::ProcessExited); + fn completed_runs_distinguish_exit_from_timeout() { + let ok = completed_response(ExecOutcome::Exited(0), 5_000); + assert_eq!((ok.exit_code, ok.failure_phase), (0, FailurePhase::None)); + + let exited = completed_response(ExecOutcome::Exited(-1), 5_000); + assert_eq!( + (exited.exit_code, exited.failure_phase), + (-1, FailurePhase::ProcessExited) + ); + + let timed_out = completed_response(ExecOutcome::TimedOut, 5_000); + assert_eq!(timed_out.failure_phase, FailurePhase::Timeout); + assert_eq!(timed_out.error_message, "script timed out after 5000ms"); } #[test] diff --git a/src/mxc-sdk/src/backends/isolation_session/common/state_aware.rs b/src/mxc-sdk/src/backends/isolation_session/common/state_aware.rs index b31c8a9b8..2f2c6ce2f 100644 --- a/src/mxc-sdk/src/backends/isolation_session/common/state_aware.rs +++ b/src/mxc-sdk/src/backends/isolation_session/common/state_aware.rs @@ -25,7 +25,9 @@ use crate::mxc_common::validator::{ use windows::Win32::Foundation::HANDLE; use super::error::map_lifecycle_error; -use super::manager::{log_sandbox_torn_down, IsolationSessionManager, TeardownOutcome}; +use super::manager::{ + log_sandbox_torn_down, IsolationSessionManager, TeardownOutcome, WAIT_FOR_EXIT_TIMEOUT, +}; use super::policy::{ reject_unhonorable_environment, validate_post_provision_policy, validate_provision_policy, }; @@ -316,9 +318,13 @@ impl StatefulSandboxBackend for IsolationSessionRunner { ); let mut logger = Logger::inherit_thread_diagnostic_sink(); - let exit_code = manager + let exit_code = match manager .create_process(&options, Some(&mut logger)) - .map_err(map_lifecycle_error)?; + .map_err(map_lifecycle_error)? + { + ExecOutcome::Exited(exit_code) => exit_code, + ExecOutcome::TimedOut => WAIT_FOR_EXIT_TIMEOUT, + }; // The output relay completed inside `create_process`. The // dispatcher sees zero pipe handles, skips its own relay setup, diff --git a/src/mxc-sdk/src/core/mxc_common/script_runner.rs b/src/mxc-sdk/src/core/mxc_common/script_runner.rs index b6b1fc8c2..910552ac5 100644 --- a/src/mxc-sdk/src/core/mxc_common/script_runner.rs +++ b/src/mxc-sdk/src/core/mxc_common/script_runner.rs @@ -145,10 +145,12 @@ fn warning_envelope_lines(warnings: &[String]) -> Vec { /// Warnings are reported on success as well as on failure, so this is separate /// from [`emit_backend_error_envelope`] rather than folded into it. One object /// per line keeps the stream parseable a line at a time, matching the error -/// envelope. +/// envelope. The batch starts on a fresh line because the workload's stderr may +/// not end in a newline. pub fn emit_warning_envelopes(warnings: &[String]) { - for line in warning_envelope_lines(warnings) { - eprintln!("{line}"); + let lines = warning_envelope_lines(warnings); + if !lines.is_empty() { + eprintln!("\n{}", lines.join("\n")); } } diff --git a/src/tools/wxc/src/main.rs b/src/tools/wxc/src/main.rs index 8a3512cbb..f72341dc1 100644 --- a/src/tools/wxc/src/main.rs +++ b/src/tools/wxc/src/main.rs @@ -656,15 +656,15 @@ fn run_state_aware_main( process::exit(0); } StateAwareExit::ExecCode(exit_code) => process::exit(exit_code), - StateAwareExit::Error(json) => { + StateAwareExit::Error(json, exit_code) => { println!("{}", json); - process::exit(1); + process::exit(exit_code); } - StateAwareExit::ExecError(json) => { + StateAwareExit::ExecError(json, exit_code) => { // The script's own stderr may not end in a newline, and gluing the // envelope onto those bytes would leave it unparseable. eprintln!("\n{}", json); - process::exit(1); + process::exit(exit_code); } } } @@ -678,10 +678,10 @@ enum StateAwareExit { Envelope(String), /// Exit with this script exit code (its output was already streamed). ExecCode(i32), - /// Print this JSON error envelope to stdout, then exit 1. - Error(String), - /// Print this JSON error envelope to stderr, then exit 1. - ExecError(String), + /// Print this JSON error envelope to stdout, then exit with the code's status. + Error(String, i32), + /// Print this JSON error envelope to stderr, then exit with the code's status. + ExecError(String, i32), } /// Map a dispatch outcome to its terminal exit action. Pure — it neither prints @@ -697,14 +697,15 @@ fn finalize_state_aware_outcome( Ok(DispatchOutcome::ExecCompleted { exit_code }) => StateAwareExit::ExecCode(exit_code), Err(e) => { let json = error_envelope_string(&e); + let exit_code = e.code.exit_code(); // A failed exec has already streamed the script's output to stdout, // so an envelope appended there would corrupt both. A dry run // streams nothing and keeps its envelope on stdout. if phase == "exec" && !dry_run { - StateAwareExit::ExecError(json) + StateAwareExit::ExecError(json, exit_code) } else { - StateAwareExit::Error(json) + StateAwareExit::Error(json, exit_code) } } } @@ -3137,11 +3138,12 @@ mod tests { let outcome: Result = Err(MxcError::malformed_request("boom".to_string())); match finalize_state_aware_outcome(outcome, "provision", false) { - StateAwareExit::Error(json) => { + StateAwareExit::Error(json, exit_code) => { // Must be a parseable envelope carrying the error code so consumers // that key off `error.code` still work. let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); assert!(parsed["error"]["code"].is_string(), "{json}"); + assert_eq!(exit_code, 1); } other => panic!("expected Error, got {other:?}"), } @@ -3152,10 +3154,11 @@ mod tests { let outcome: Result = Err(MxcError::backend_error("output was truncated".to_string())); match finalize_state_aware_outcome(outcome, "exec", false) { - StateAwareExit::ExecError(json) => { + StateAwareExit::ExecError(json, exit_code) => { // Still a parseable envelope; only the stream it lands on changes. let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); assert!(parsed["error"]["code"].is_string(), "{json}"); + assert_eq!(exit_code, -1); } other => panic!("expected ExecError, got {other:?}"), } @@ -3167,7 +3170,7 @@ mod tests { Err(MxcError::malformed_request("boom".to_string())); assert!(matches!( finalize_state_aware_outcome(outcome, "exec", true), - StateAwareExit::Error(_) + StateAwareExit::Error(_, 1) )); } } From c1c3b29a2e90a994a8b2945cad84320d189188f0 Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:26:11 -0700 Subject: [PATCH 13/17] fixes: teardown outcome shadowing execoutcome, ScriptResponse default incorrectly used --- .../src/backends/isolation_session/common/one_shot.rs | 11 +++++++---- src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs | 6 +++--- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/mxc-sdk/src/backends/isolation_session/common/one_shot.rs b/src/mxc-sdk/src/backends/isolation_session/common/one_shot.rs index 89d523606..544fd87c7 100644 --- a/src/mxc-sdk/src/backends/isolation_session/common/one_shot.rs +++ b/src/mxc-sdk/src/backends/isolation_session/common/one_shot.rs @@ -122,8 +122,8 @@ impl ScriptRunner for IsolationSessionRunner { return e.into(); } - let outcome = match manager.create_process(&options, Some(logger)) { - Ok(outcome) => outcome, + let exec_outcome = match manager.create_process(&options, Some(logger)) { + Ok(exec_outcome) => exec_outcome, Err(e) => { let stopped = manager.stop_session().is_ok(); let deprovisioned = manager.deprovision_agent_user().is_ok(); @@ -159,7 +159,7 @@ impl ScriptRunner for IsolationSessionRunner { // Output already streamed live to wxc-exec's stdio via relay // threads in `create_process` — captured fields intentionally // empty (same pattern as AppContainer). - completed_response(outcome, options.timeout_ms) + completed_response(exec_outcome, options.timeout_ms) } } @@ -167,7 +167,10 @@ impl ScriptRunner for IsolationSessionRunner { /// told apart from the sandbox failing around it. fn completed_response(outcome: ExecOutcome, timeout_ms: u32) -> ScriptResponse { match outcome { - ExecOutcome::Exited(0) => ScriptResponse::default(), + ExecOutcome::Exited(0) => ScriptResponse { + exit_code: 0, + ..Default::default() + }, ExecOutcome::Exited(exit_code) => ScriptResponse { exit_code, failure_phase: FailurePhase::ProcessExited, diff --git a/src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs b/src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs index 35aeb2ce3..6fc8ddff2 100644 --- a/src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs +++ b/src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs @@ -2937,9 +2937,9 @@ mod tests { "the fail-closed readiness timeout message changed unexpectedly: {}", response.error_message ); - assert_eq!( - response.standard_err, response.error_message, - "error responses should carry the message in stderr as well" + assert!( + response.standard_err.is_empty(), + "the message belongs in the error envelope, not repeated in stderr" ); } From ab784325f77da7672653a373525febaa1df4d37e Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:34:06 -0700 Subject: [PATCH 14/17] Classify Seatbelt profile host errors and emit dry-run error envelopes - Seatbelt profile building separates policy errors from host setup errors; a missing HOME is LaunchFailed rather than a policy rejection. - Failed create-and-run dry runs emit the typed error envelope on stderr. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1c677fc2-77c3-43b0-a06f-da11d11447c8 --- .../seatbelt/common/profile_builder.rs | 46 ++++++++++++++----- .../seatbelt/common/seatbelt_runner.rs | 11 ++++- .../src/core/mxc_common/script_runner.rs | 2 + 3 files changed, 46 insertions(+), 13 deletions(-) diff --git a/src/mxc-sdk/src/backends/seatbelt/common/profile_builder.rs b/src/mxc-sdk/src/backends/seatbelt/common/profile_builder.rs index eaf365f0b..d4562986a 100644 --- a/src/mxc-sdk/src/backends/seatbelt/common/profile_builder.rs +++ b/src/mxc-sdk/src/backends/seatbelt/common/profile_builder.rs @@ -34,6 +34,23 @@ use crate::mxc_common::host_is_canonical_loopback; use crate::mxc_common::models::{ClipboardPolicy, ContainerPolicy, ExecutionRequest, ProxyAddress}; use crate::seatbelt_common::seatbelt_policy; +/// Why a profile could not be built: the request, or the executor's host. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProfileError { + /// The request names something the profile cannot express. + Policy(String), + /// The executor's environment lacks something the profile needs. + Host(String), +} + +impl std::fmt::Display for ProfileError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Policy(message) | Self::Host(message) => f.write_str(message), + } + } +} + /// Build a complete Seatbelt sandbox profile, scoping cooperative proxy /// reachability to the resolved address supplied by the runner. /// @@ -44,7 +61,7 @@ use crate::seatbelt_common::seatbelt_policy; pub fn build_profile_with_proxy( request: &ExecutionRequest, proxy_address: Option<&ProxyAddress>, -) -> Result { +) -> Result { if let Some(override_profile) = request .seatbelt .as_ref() @@ -283,7 +300,7 @@ struct ResolvedPaths { } impl ResolvedPaths { - fn from_policy(policy: &ContainerPolicy) -> Result { + fn from_policy(policy: &ContainerPolicy) -> Result { let denied = resolve_all(&policy.denied_paths)?; let readonly = resolve_all(&policy.readonly_paths)?; let mut readwrite = resolve_all(&policy.readwrite_paths)?; @@ -303,7 +320,7 @@ impl ResolvedPaths { } } -fn resolve_all(paths: &[String]) -> Result, String> { +fn resolve_all(paths: &[String]) -> Result, ProfileError> { paths.iter().map(|p| resolve_policy_path(p)).collect() } @@ -636,7 +653,7 @@ fn write_nested_pty_rules(out: &mut String, request: &ExecutionRequest) { /// stores under `/Library/Keychains` and `/System/Library/Keychains` /// are already covered by the baseline `/Library` and `/System` /// read-only allows, so we don't re-add them here. -fn write_keychain_rules(out: &mut String, request: &ExecutionRequest) -> Result<(), String> { +fn write_keychain_rules(out: &mut String, request: &ExecutionRequest) -> Result<(), ProfileError> { let enabled = request.seatbelt.as_ref().is_some_and(|c| c.keychain_access); if !enabled { return Ok(()); @@ -673,8 +690,11 @@ fn write_keychain_rules(out: &mut String, request: &ExecutionRequest) -> Result< out.push_str(" (subpath \"/private/var/db/mds\"))\n"); let home = std::env::var("HOME").map_err(|_| { - "HOME environment variable not set; cannot expand '~/Library/Keychains' for keychainAccess" - .to_string() + ProfileError::Host( + "HOME environment variable not set; cannot expand '~/Library/Keychains' for \ + keychainAccess" + .to_string(), + ) })?; let user_keychains = format!("{home}/Library/Keychains"); out.push_str(";; --- keychainAccess: user keychain DB + XPC/folder caches (read+write) ---\n"); @@ -708,9 +728,10 @@ fn write_extra_seatbelt_rules(out: &mut String, request: &ExecutionRequest) { /// /// All three steps are required. See [`expand_tilde`], [`normalize_lexical`] /// and [`resolve_macos_root_symlinks`]. -pub(crate) fn resolve_policy_path(path: &str) -> Result { - let expanded = expand_tilde(path)?; - Ok(resolve_macos_root_symlinks(&normalize_lexical(&expanded)?)) +pub(crate) fn resolve_policy_path(path: &str) -> Result { + let expanded = expand_tilde(path).map_err(ProfileError::Host)?; + let normalized = normalize_lexical(&expanded).map_err(ProfileError::Policy)?; + Ok(resolve_macos_root_symlinks(&normalized)) } /// Collapse the lexical spellings of a path that leave a Seatbelt rule dead: @@ -830,7 +851,7 @@ mod tests { use super::*; use crate::mxc_common::models::{NetworkAction, SeatbeltConfig, UiPolicy}; - fn build_profile(request: &ExecutionRequest) -> Result { + fn build_profile(request: &ExecutionRequest) -> Result { build_profile_with_proxy(request, request.policy.network_proxy.address.as_ref()) } @@ -1519,7 +1540,10 @@ mod tests { // would silently widen an allow and leave a deny dead. for path in ["/private/var/../tmp", "/tmp/..", "/..", "/a/b/../c"] { let err = resolve_policy_path(path).unwrap_err(); - assert!(err.contains(".."), "{path} must be rejected, got {err}"); + assert!( + matches!(&err, ProfileError::Policy(message) if message.contains("..")), + "{path} must be rejected as policy, got {err:?}" + ); } // A path merely *containing* dots in a segment name is fine. assert_eq!( diff --git a/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs b/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs index d7ebe7990..275649b2a 100644 --- a/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs +++ b/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs @@ -47,7 +47,7 @@ use crate::mxc_pty::{LivePty, PtySize as UnixPtySize}; #[cfg(test)] use crate::seatbelt_common::default_env::DEFAULT_SANDBOX_PATH; use crate::seatbelt_common::default_env::{env_pairs, resolved_env}; -use crate::seatbelt_common::profile_builder::build_profile_with_proxy; +use crate::seatbelt_common::profile_builder::{build_profile_with_proxy, ProfileError}; #[link(name = "proc")] unsafe extern "C" { @@ -310,7 +310,7 @@ impl SandboxBackend for SeatbeltScriptRunner { )); } let profile = - build_profile_with_proxy(request, proxy_address).map_err(rejected_response)?; + build_profile_with_proxy(request, proxy_address).map_err(profile_error_response)?; log_generated_profile(&profile, logger); // Determine launch method + GUI access from the seatbelt config. @@ -1097,6 +1097,13 @@ fn launch_failed_response(message: String) -> ScriptResponse { ScriptResponse::failed(FailurePhase::LaunchFailed, &message) } +fn profile_error_response(error: ProfileError) -> ScriptResponse { + match error { + ProfileError::Policy(message) => rejected_response(message), + ProfileError::Host(message) => launch_failed_response(message), + } +} + /// The optional run timeout — `None` when `scriptTimeout` is 0 (wait forever). fn timeout_from(request: &ExecutionRequest) -> Option { if request.script_timeout == 0 { diff --git a/src/mxc-sdk/src/core/mxc_common/script_runner.rs b/src/mxc-sdk/src/core/mxc_common/script_runner.rs index 910552ac5..2738d04c3 100644 --- a/src/mxc-sdk/src/core/mxc_common/script_runner.rs +++ b/src/mxc-sdk/src/core/mxc_common/script_runner.rs @@ -76,10 +76,12 @@ fn dry_run_summary(response: &ScriptResponse) -> String { } /// Print a dry-run result message to the logger, flush, and exit the process. +/// A failed dry run also emits its typed error envelope on stderr. pub fn handle_dry_run_exit(response: &ScriptResponse, logger: &mut Logger) -> ! { use std::fmt::Write; let _ = writeln!(logger, "{}", dry_run_summary(response)); print!("{}", logger.get_buffer()); + emit_backend_error_envelope(response); std::process::exit(response.exit_code); } From 2ed5f6f156807b44a75cfccd0518feac8df13540 Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:47:55 -0700 Subject: [PATCH 15/17] Keep IsolationSession timeouts typed and envelopes on fresh lines - create_process arms the service timer with the grace margin and enforces the caller's deadline locally, so a timeout reports backend_error/exit -1. - Error envelopes start on a fresh stderr line. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1c677fc2-77c3-43b0-a06f-da11d11447c8 --- .../backends/isolation-session/state-aware-rust.md | 12 +++++------- .../backends/isolation_session/common/manager.rs | 13 +++++++++++-- .../isolation_session/common/process_options.rs | 6 ++---- src/mxc-sdk/src/core/mxc_common/script_runner.rs | 5 +++-- tests/scripts/run_isolation_session_tests.ps1 | 7 +++---- 5 files changed, 24 insertions(+), 19 deletions(-) diff --git a/docs/development/architecture/backends/isolation-session/state-aware-rust.md b/docs/development/architecture/backends/isolation-session/state-aware-rust.md index 9303c2ca4..bda52983e 100644 --- a/docs/development/architecture/backends/isolation-session/state-aware-rust.md +++ b/docs/development/architecture/backends/isolation-session/state-aware-rust.md @@ -505,13 +505,11 @@ State-aware exec (and other phases) use OS-level cancellation in v1: - On the `wxc-exec` route, cancellation is process termination. - The agent process's pipes EOF, the relay threads exit. - The OS-side service's per-process timer (set from - `process.timeout`) reaps the agent if the runner does not. On the - streaming path that timer is armed with a **margin** past the caller's - deadline, so it acts purely as a watchdog: the service kills with an - ordinary exit code (the host suite pins it as exit code 1), so if it - fired first a genuine timeout would be indistinguishable from a normal - exit and could not be reported as one. The run-to-completion path arms - it unchanged — it has no timeout channel to report through. + `process.timeout`) reaps the agent if the runner does not. That timer is + armed with a **margin** past the caller's deadline, so it acts purely as + a watchdog: the service kills with an ordinary exit code, so if it fired + first a genuine timeout would be indistinguishable from a normal exit + and could not be reported as one. - The runner's existing 3-tier shutdown (`CloseStandardInput` → `SendCtrlClose` → `Terminate`) handles the timeout case from inside the agent process before returning. diff --git a/src/mxc-sdk/src/backends/isolation_session/common/manager.rs b/src/mxc-sdk/src/backends/isolation_session/common/manager.rs index 0a18d5ade..c17df2681 100644 --- a/src/mxc-sdk/src/backends/isolation_session/common/manager.rs +++ b/src/mxc-sdk/src/backends/isolation_session/common/manager.rs @@ -37,7 +37,9 @@ use super::owned_thread::{self, Impersonation}; use super::pipe_relay::{ create_relay_thread, create_relay_thread_with_stop, duplicate_handle, PipeRelayWithStopParams, }; -use super::process_options::{build_iso_process_options, ProcessOptions}; +use super::process_options::{ + build_iso_process_options, with_service_timeout_grace, ProcessOptions, +}; // The OS account name is lifecycle addressing data, not diagnostic identity. const AGENT_IDENTITY_MARKER: &str = "isolation-session-agent"; @@ -400,9 +402,15 @@ impl IsolationSessionManager { /// to completion. Output is streamed live to the calling process's stdio via /// internal relay threads; only the exit code or timeout is returned. /// + /// `options.timeout_ms` is enforced locally; the service-side timer is armed + /// with [`with_service_timeout_grace`] so a timeout is never reported as an + /// ordinary exit. + /// /// This is the **relay** path, reached by `wxc-exec` and by an in-process /// caller that asked to attach. A caller that wants the streams handed back /// uses [`Self::piped_exec_handle`] instead. + /// + /// [`with_service_timeout_grace`]: super::process_options::with_service_timeout_grace pub(super) fn create_process( &self, options: &ProcessOptions, @@ -448,7 +456,8 @@ impl IsolationSessionManager { // Lifetime: each relay's param struct is moved to the heap and owned // by its thread, which frees it on exit, so joining is not required // for memory safety. - let started = self.start_process(options, logger.as_deref())?; + let service_options = with_service_timeout_grace(options.clone()); + let started = self.start_process(&service_options, logger.as_deref())?; let process = &started.process; let stdout_handle_val = started.stdout; let stderr_handle_val = started.stderr; diff --git a/src/mxc-sdk/src/backends/isolation_session/common/process_options.rs b/src/mxc-sdk/src/backends/isolation_session/common/process_options.rs index d40558ca9..e0968cc39 100644 --- a/src/mxc-sdk/src/backends/isolation_session/common/process_options.rs +++ b/src/mxc-sdk/src/backends/isolation_session/common/process_options.rs @@ -135,10 +135,8 @@ pub(super) const SERVICE_TIMEOUT_GRACE_MS: u32 = 5_000; /// Relaxes the service-side timer so the caller's deadline is enforced locally. /// -/// Only for the streaming path, which has somewhere to report a timeout. The -/// run-to-completion path keeps the service timer exactly as it was: it has no -/// timeout channel, and its observable behaviour (exit code 1 on an OS-side -/// timeout) is pinned by the host suite. +/// Without the margin the two deadlines coincide, and the service's kill (an +/// ordinary exit code 1) could win the race and hide the timeout. /// /// Two deadlines are left alone rather than shifted: /// diff --git a/src/mxc-sdk/src/core/mxc_common/script_runner.rs b/src/mxc-sdk/src/core/mxc_common/script_runner.rs index 2738d04c3..8c9670088 100644 --- a/src/mxc-sdk/src/core/mxc_common/script_runner.rs +++ b/src/mxc-sdk/src/core/mxc_common/script_runner.rs @@ -102,10 +102,11 @@ pub fn handle_dry_run_exit(response: &ScriptResponse, logger: &mut Logger) -> ! /// [`FailurePhase`](crate::mxc_common::models::FailurePhase), so a refused policy is /// reported as `policy_validation` and a host that cannot provide the backend /// as `backend_unavailable`, rather than both being flattened into the generic -/// `backend_error` every other failure uses. +/// `backend_error` every other failure uses. The envelope starts on a fresh line +/// because the workload's stderr may not end in a newline. pub fn emit_backend_error_envelope(response: &ScriptResponse) { if let Some(line) = backend_error_envelope_line(response) { - eprintln!("{line}"); + eprintln!("\n{line}"); } } diff --git a/tests/scripts/run_isolation_session_tests.ps1 b/tests/scripts/run_isolation_session_tests.ps1 index b92bdb62d..8571c13cd 100644 --- a/tests/scripts/run_isolation_session_tests.ps1 +++ b/tests/scripts/run_isolation_session_tests.ps1 @@ -365,11 +365,10 @@ $null = $results.Add((Run-IsolationSessionTest "isolation_session_stderr.json" ` # must appear in the captured output (proves streams aren't crossed or dropped mid-run). $null = $results.Add((Run-IsolationSessionTest "isolation_session_stdout_stderr_interleaved.json" ` -OutputContains @("OUT_A", "ERR_A", "OUT_B", "ERR_B", "OUT_C"))) -# Timeout: ping runs ~30s against a 1500ms deadline. The service-side timer and -# the local wait report different codes, and either can win the race to end the -# run. +# Timeout: ping runs ~30s against a 1500ms deadline. The local wait enforces the +# deadline, so the run reports a typed timeout rather than the service's kill. $null = $results.Add((Run-IsolationSessionTest "isolation_session_timeout.json" ` - -ExpectedExitAnyOf 1, -1)) + -ExpectedExit -1 -OutputContains @('"code":"backend_error"', 'timed out after'))) # Any IsolationSession-specific object is rejected at the same closed exact # contract boundary, before the command can run. From 3afba2487d4b1d0121d6c62aad0ad35e3af908a7 Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:44:55 -0700 Subject: [PATCH 16/17] Report relayed lifecycle exec timeouts as backend errors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1c677fc2-77c3-43b0-a06f-da11d11447c8 --- .../isolation-session/state-aware-rust.md | 3 +- .../isolation_session/common/manager.rs | 2 +- .../isolation_session/common/state_aware.rs | 36 ++++++++++++++----- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/docs/development/architecture/backends/isolation-session/state-aware-rust.md b/docs/development/architecture/backends/isolation-session/state-aware-rust.md index bda52983e..c175d05f6 100644 --- a/docs/development/architecture/backends/isolation-session/state-aware-rust.md +++ b/docs/development/architecture/backends/isolation-session/state-aware-rust.md @@ -557,7 +557,8 @@ An exited process is never routed through the shutdown ladder, which reads only and so cannot tell a `259` exit from a live process. The adapter maps `TimedOut` onto `ErrorKind::TimedOut`, which is what `mxc_sdk::v1::MxcProcess::wait` reads as `WaitResult::TimedOut`. That outcome -is reachable only under `Piped`; the `Relayed` arm reports `Exited`. +is reachable only under `Piped`; the `Relayed` arm reports a timeout as a +`backend_error` instead. Teardown is bounded only insofar as the kill is: the streaming adapter's `Drop` joins the waiter when the kill was accepted, and abandons the diff --git a/src/mxc-sdk/src/backends/isolation_session/common/manager.rs b/src/mxc-sdk/src/backends/isolation_session/common/manager.rs index c17df2681..505334c0b 100644 --- a/src/mxc-sdk/src/backends/isolation_session/common/manager.rs +++ b/src/mxc-sdk/src/backends/isolation_session/common/manager.rs @@ -1225,7 +1225,7 @@ const STILL_ACTIVE: i32 = windows::Win32::Foundation::STATUS_PENDING.0; /// What `WaitForExit` returns when the deadline elapses before the process /// exits. Like [`STILL_ACTIVE`] this is a legal exit code in its own right, so /// it is never used alone to conclude a timeout. -pub(super) const WAIT_FOR_EXIT_TIMEOUT: i32 = -1; +const WAIT_FOR_EXIT_TIMEOUT: i32 = -1; /// How long [`StartedProcess::terminate`] waits for a kill to land before /// reporting success anyway. Bounded so a failed `Terminate` cannot wedge that diff --git a/src/mxc-sdk/src/backends/isolation_session/common/state_aware.rs b/src/mxc-sdk/src/backends/isolation_session/common/state_aware.rs index 2f2c6ce2f..43681271a 100644 --- a/src/mxc-sdk/src/backends/isolation_session/common/state_aware.rs +++ b/src/mxc-sdk/src/backends/isolation_session/common/state_aware.rs @@ -25,9 +25,7 @@ use crate::mxc_common::validator::{ use windows::Win32::Foundation::HANDLE; use super::error::map_lifecycle_error; -use super::manager::{ - log_sandbox_torn_down, IsolationSessionManager, TeardownOutcome, WAIT_FOR_EXIT_TIMEOUT, -}; +use super::manager::{log_sandbox_torn_down, IsolationSessionManager, TeardownOutcome}; use super::policy::{ reject_unhonorable_environment, validate_post_provision_policy, validate_provision_policy, }; @@ -318,13 +316,10 @@ impl StatefulSandboxBackend for IsolationSessionRunner { ); let mut logger = Logger::inherit_thread_diagnostic_sink(); - let exit_code = match manager + let outcome = manager .create_process(&options, Some(&mut logger)) - .map_err(map_lifecycle_error)? - { - ExecOutcome::Exited(exit_code) => exit_code, - ExecOutcome::TimedOut => WAIT_FOR_EXIT_TIMEOUT, - }; + .map_err(map_lifecycle_error)?; + let exit_code = relayed_exit_code(outcome, options.timeout_ms)?; // The output relay completed inside `create_process`. The // dispatcher sees zero pipe handles, skips its own relay setup, @@ -373,6 +368,17 @@ impl StatefulSandboxBackend for IsolationSessionRunner { } } +/// A relayed exec reports an exit code, so a timeout must surface as an error +/// rather than an exit code a workload could also return. +fn relayed_exit_code(outcome: ExecOutcome, timeout_ms: u32) -> Result { + match outcome { + ExecOutcome::Exited(exit_code) => Ok(exit_code), + ExecOutcome::TimedOut => Err(MxcError::backend_error(format!( + "script timed out after {timeout_ms}ms" + ))), + } +} + #[cfg(test)] mod tests { use super::*; @@ -382,6 +388,18 @@ mod tests { }; use crate::mxc_common::mxc_error::MxcErrorCode; + #[test] + fn a_relayed_timeout_is_an_error_not_an_exit_code() { + assert_eq!( + relayed_exit_code(ExecOutcome::Exited(-1), 5_000).unwrap(), + -1 + ); + + let error = relayed_exit_code(ExecOutcome::TimedOut, 5_000).unwrap_err(); + assert_eq!(error.code, MxcErrorCode::BackendError); + assert_eq!(error.message, "script timed out after 5000ms"); + } + // ====== Wire-format constants ====== // Checked binding verifies BACKEND_KEY before delivering configuration. From 7d25f2890166fef442949556495d1a9efb5e95b4 Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:20:15 -0700 Subject: [PATCH 17/17] Classify unexaminable filesystem policy paths as launch failures Access denied stays a policy rejection; an unreachable mount or I/O error is a host fault. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1c677fc2-77c3-43b0-a06f-da11d11447c8 --- .../bubblewrap/common/bwrap_runner.rs | 2 +- .../src/backends/lxc/common/lxc_runner.rs | 2 +- .../backends/wslc/common/policy_mapping.rs | 13 +- .../src/backends/wslc/common/state_aware.rs | 13 +- .../wslc/common/wsl_container_runner.rs | 11 +- .../src/core/mxc_common/filesystem_object.rs | 118 +++++++++++++----- 6 files changed, 115 insertions(+), 44 deletions(-) diff --git a/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs b/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs index 99f4c19ab..4cbac1d8e 100644 --- a/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs +++ b/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs @@ -124,7 +124,7 @@ impl SandboxBackend for BubblewrapScriptRunner { &normalized } Ok(None) => request, - Err(msg) => return Err(ScriptResponse::rejected(&msg)), + Err(error) => return Err(error.into_response()), }; // Reject any policy path the invoking user cannot access, so the sandbox // never gains access the caller lacks. Runs after object normalization diff --git a/src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs b/src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs index 6fc8ddff2..9f05846fb 100644 --- a/src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs +++ b/src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs @@ -454,7 +454,7 @@ impl LxcScriptRunner { &normalized } Ok(None) => request, - Err(msg) => return Err(ScriptResponse::rejected(&msg)), + Err(error) => return Err(error.into_response()), }; if let Err(msg) = crate::mxc_common::filesystem_access::check_delegation(&request.policy) { return Err(ScriptResponse::rejected(&msg)); diff --git a/src/mxc-sdk/src/backends/wslc/common/policy_mapping.rs b/src/mxc-sdk/src/backends/wslc/common/policy_mapping.rs index d26035b9e..96c75ec64 100644 --- a/src/mxc-sdk/src/backends/wslc/common/policy_mapping.rs +++ b/src/mxc-sdk/src/backends/wslc/common/policy_mapping.rs @@ -10,6 +10,7 @@ //! injected, so its logic stays testable without touching disk. use crate::mxc_common::filesystem_canonical::{canonicalize_allowing_absent_tail, PathCanonical}; +use crate::mxc_common::filesystem_object::FilesystemPolicyError; use crate::mxc_common::logger::Logger; use crate::mxc_common::models::{ContainerPolicy, ExecutionRequest}; use crate::wslc_common::wslc_bindings::WslcContainerNetworkingMode; @@ -331,22 +332,24 @@ pub fn validate_denied_path_overlap( /// /// Returns the tightened policy when normalization changed something (the caller /// rebuilds the request around it) or `None` when the policy was already -/// conflict-free. Any failure is returned as a `String`; callers map it to their -/// own error envelope (`ScriptResponse` / `MxcError`). Normalization diagnostics +/// conflict-free. Callers map a failure to their own error envelope +/// (`ScriptResponse` / `MxcError`). Normalization diagnostics /// are written to `logger`; callers decide how to surface them. pub fn apply_provision_policy_gate( request: &ExecutionRequest, logger: &mut Logger, -) -> Result, String> { +) -> Result, FilesystemPolicyError> { let normalized = crate::mxc_common::filesystem_object::normalize_object_conflicts(&request.policy, logger)?; let effective = normalized.as_ref().unwrap_or(&request.policy); - crate::mxc_common::filesystem_access::check_delegation(effective)?; + crate::mxc_common::filesystem_access::check_delegation(effective) + .map_err(FilesystemPolicyError::Policy)?; validate_denied_path_overlap( &effective.readwrite_paths, &effective.readonly_paths, &effective.denied_paths, - )?; + ) + .map_err(FilesystemPolicyError::Policy)?; Ok(normalized) } diff --git a/src/mxc-sdk/src/backends/wslc/common/state_aware.rs b/src/mxc-sdk/src/backends/wslc/common/state_aware.rs index ba005f5ce..5db700da9 100644 --- a/src/mxc-sdk/src/backends/wslc/common/state_aware.rs +++ b/src/mxc-sdk/src/backends/wslc/common/state_aware.rs @@ -21,6 +21,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; #[cfg(windows)] use std::sync::Arc; +use crate::mxc_common::filesystem_object::FilesystemPolicyError; use crate::mxc_common::logger::{Logger, Mode}; use crate::mxc_common::models::{ContainerPolicy, ExecutionRequest, WslcProvisionConfig}; use crate::mxc_common::mxc_error::MxcError; @@ -583,9 +584,10 @@ fn build_provision_config( /// ([`crate::wslc_common::policy_mapping::apply_provision_policy_gate`]): runs the full /// three-step gate (D6 normalization → D3 delegation → denied-path overlap), /// buffering normalization diagnostics and surfacing them on stderr (stdout -/// carries the phase envelope), and maps the gate's `String` error to a -/// `policy_validation` [`MxcError`]. Returns the tightened policy when -/// normalization changed something, else `None`. +/// carries the phase envelope), and maps a gate failure to a `policy_validation` +/// or, for a path that could not be examined, a `backend_error` [`MxcError`]. +/// Returns the tightened policy when normalization changed something, else +/// `None`. fn normalize_and_check_delegation( request: &ExecutionRequest, ) -> Result, MxcError> { @@ -598,7 +600,10 @@ fn normalize_and_check_delegation( if !notes.is_empty() { eprint!("{notes}"); } - result.map_err(MxcError::policy_validation) + result.map_err(|error| match error { + FilesystemPolicyError::Policy(message) => MxcError::policy_validation(message), + FilesystemPolicyError::Host(message) => MxcError::backend_error(message), + }) } /// Build daemon volume mounts from the request's filesystem policy. Overlapping diff --git a/src/mxc-sdk/src/backends/wslc/common/wsl_container_runner.rs b/src/mxc-sdk/src/backends/wslc/common/wsl_container_runner.rs index bd6d8c23d..921430227 100644 --- a/src/mxc-sdk/src/backends/wslc/common/wsl_container_runner.rs +++ b/src/mxc-sdk/src/backends/wslc/common/wsl_container_runner.rs @@ -23,6 +23,7 @@ use std::ptr; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Condvar, Mutex}; +use crate::mxc_common::filesystem_object::FilesystemPolicyError; use crate::mxc_common::logger::{Logger, Mode}; use crate::mxc_common::models::{ExecutionRequest, ScriptResponse, WslcConfig}; use crate::mxc_common::mxc_error::MxcError; @@ -836,9 +837,13 @@ impl WSLContainerRunner { &normalized } Ok(None) => request, - Err(msg) => { - let _ = writeln!(logger, "[WSLC] {}", msg); - return Err(WslcError::Rejected(msg).into_response()); + Err(error) => { + let _ = writeln!(logger, "[WSLC] {}", error.message()); + return Err(match error { + FilesystemPolicyError::Policy(message) => WslcError::Rejected(message), + FilesystemPolicyError::Host(message) => WslcError::Host(message), + } + .into_response()); } }; diff --git a/src/mxc-sdk/src/core/mxc_common/filesystem_object.rs b/src/mxc-sdk/src/core/mxc_common/filesystem_object.rs index 3e634a668..6c96460c9 100644 --- a/src/mxc-sdk/src/core/mxc_common/filesystem_object.rs +++ b/src/mxc-sdk/src/core/mxc_common/filesystem_object.rs @@ -25,8 +25,10 @@ //! denied, unreachable mount, I/O error — as opposed to *cleanly missing*) and //! `deniedPaths` are present, MXC cannot prove that path is not an undetectable //! alias that would bypass a deny. Rather than proceed looser than intended, the -//! config is **rejected** (fail closed). Cleanly-missing paths do not trigger -//! this — they are genuinely absent, so there is nothing to alias. +//! run is **refused** (fail closed): a path the caller cannot access is a policy +//! rejection, and any other unexaminable path is a host fault. Cleanly-missing +//! paths do not trigger this — they are genuinely absent, so there is nothing to +//! alias. //! //! This does file I/O (`stat`/`CreateFile`), so — per design review — it lives //! here in `mxc_common` and is invoked by each backend runner close to the @@ -35,7 +37,7 @@ //! and the backend actually building its mounts. use crate::mxc_common::logger::Logger; -use crate::mxc_common::models::ContainerPolicy; +use crate::mxc_common::models::{ContainerPolicy, FailurePhase, ScriptResponse}; use std::path::Path; /// Intent class for a policy path, ordered least → most restrictive so that @@ -89,12 +91,37 @@ enum PathResolution { /// Nothing to alias; safe to skip. (Existence is surfaced separately by the /// parse-time existence warning.) Absent, + /// The caller is denied access to the path, so its identity cannot be read. + Inaccessible, /// The path exists (or may exist) but its identity could not be determined — - /// permission denied, an unreachable mount, or another I/O error. This is - /// the fail-closed trigger when `deniedPaths` are present. + /// an unreachable mount or another I/O error. Unknown, } +/// Why a filesystem policy was refused. +#[derive(Debug, PartialEq, Eq)] +pub enum FilesystemPolicyError { + /// The request is at fault, e.g. a policy path the caller cannot access. + Policy(String), + /// A policy path could not be examined; the same request may succeed later. + Host(String), +} + +impl FilesystemPolicyError { + pub fn message(&self) -> &str { + match self { + Self::Policy(message) | Self::Host(message) => message, + } + } + + pub fn into_response(self) -> ScriptResponse { + match self { + Self::Policy(message) => ScriptResponse::rejected(&message), + Self::Host(message) => ScriptResponse::failed(FailurePhase::LaunchFailed, &message), + } + } +} + /// Result of comparing two paths by filesystem-object identity. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum ExistingObjectComparison { @@ -128,6 +155,7 @@ fn resolve_object(path: &Path) -> PathResolution { // could not examine the path. Err(e) => match e.raw_os_error() { Some(libc::ENOENT) | Some(libc::ENOTDIR) => PathResolution::Absent, + Some(libc::EACCES) | Some(libc::EPERM) => PathResolution::Inaccessible, _ => PathResolution::Unknown, }, } @@ -138,7 +166,7 @@ fn resolve_object(path: &Path) -> PathResolution { use std::os::windows::ffi::OsStrExt; use windows::core::PCWSTR; use windows::Win32::Foundation::{ - CloseHandle, GetLastError, ERROR_FILE_NOT_FOUND, ERROR_PATH_NOT_FOUND, + CloseHandle, GetLastError, ERROR_ACCESS_DENIED, ERROR_FILE_NOT_FOUND, ERROR_PATH_NOT_FOUND, }; use windows::Win32::Storage::FileSystem::{ CreateFileW, FileIdInfo, GetFileInformationByHandleEx, FILE_FLAG_BACKUP_SEMANTICS, @@ -178,6 +206,8 @@ fn resolve_object(path: &Path) -> PathResolution { let err = unsafe { GetLastError() }; return if err == ERROR_FILE_NOT_FOUND || err == ERROR_PATH_NOT_FOUND { classify_not_found_leaf_windows(path) + } else if err == ERROR_ACCESS_DENIED { + PathResolution::Inaccessible } else { PathResolution::Unknown }; @@ -253,7 +283,8 @@ fn compare_resolved_filesystem_objects( // Unknown must take precedence over Absent on every platform. Treating // an unexaminable path as merely different would let callers proceed // without proving that the policy paths refer to distinct objects. - (PathResolution::Unknown, _) | (_, PathResolution::Unknown) => { + (PathResolution::Unknown | PathResolution::Inaccessible, _) + | (_, PathResolution::Unknown | PathResolution::Inaccessible) => { ExistingObjectComparison::Unknown } (PathResolution::Absent, _) | (_, PathResolution::Absent) => { @@ -277,18 +308,17 @@ fn compare_resolved_filesystem_objects( /// - `Ok(None)` when there is nothing to change (the common case — no /// symlink / hard-link / bind-mount aliases), so callers can avoid cloning the /// request entirely. -/// - `Err(message)` when the config must be **rejected**: a path could not be -/// resolved to an object (permission denied, unreachable mount, I/O error — -/// *not* cleanly missing) while `deniedPaths` are present, so an alias that -/// would bypass a deny cannot be ruled out (fail closed). The message is -/// suitable for surfacing as a backend error. +/// - `Err(_)` when the run must be **refused**: a path could not be resolved to +/// an object (permission denied, unreachable mount, I/O error — *not* cleanly +/// missing) while `deniedPaths` are present, so an alias that would bypass a +/// deny cannot be ruled out (fail closed). /// /// Run this *after* the string-level normalization in `config_parser`, close to /// where the backend builds its mounts. pub fn normalize_object_conflicts( policy: &ContainerPolicy, logger: &mut Logger, -) -> Result, String> { +) -> Result, FilesystemPolicyError> { let total = policy.readwrite_paths.len() + policy.readonly_paths.len() + policy.denied_paths.len(); // A conflict needs at least two paths resolving to the same object, so a @@ -314,10 +344,11 @@ pub fn normalize_object_conflicts( // Group entry indices by object identity. Resolution has three outcomes: // - `Object`: grouped for conflict detection. // - `Absent` (cleanly missing): skipped — nothing to alias. - // - `Unknown` (exists/maybe-exists but unexaminable): when `deniedPaths` are - // present we cannot prove this path is not an undetectable alias that would - // bypass a deny, so we FAIL CLOSED and reject. Without `deniedPaths` there - // is no deny to bypass, so we log it and leave it in place. + // - `Inaccessible` / `Unknown` (exists/maybe-exists but unexaminable): when + // `deniedPaths` are present we cannot prove this path is not an + // undetectable alias that would bypass a deny, so we FAIL CLOSED. Without + // `deniedPaths` there is no deny to bypass, so we log it and leave it in + // place. let has_denied = !policy.denied_paths.is_empty(); let mut groups: HashMap> = HashMap::new(); for (i, (path, intent)) in entries.iter().enumerate() { @@ -326,18 +357,23 @@ pub fn normalize_object_conflicts( groups.entry(id).or_default().push(i); } PathResolution::Absent => {} + PathResolution::Inaccessible if has_denied => { + return Err(FilesystemPolicyError::Policy(unresolvable_message( + path, + *intent, + "permission denied", + "Grant access to the path, or remove the offending entry.", + ))); + } PathResolution::Unknown if has_denied => { - return Err(format!( - "Filesystem path '{}' ({}) could not be resolved to a filesystem object \ - (permission denied, unreachable mount, or I/O error). Because deniedPaths \ - are present, MXC cannot verify it does not alias a denied object and rejects \ - the config rather than risk bypassing the deny. Ensure the path is reachable, \ - or remove the offending entry.", + return Err(FilesystemPolicyError::Host(unresolvable_message( path, - intent.list_name(), - )); + *intent, + "unreachable mount or I/O error", + "Retry once the path is reachable, or remove the offending entry.", + ))); } - PathResolution::Unknown => { + PathResolution::Inaccessible | PathResolution::Unknown => { logger.log_line(&format!( "WARNING: filesystem path '{}' ({}) could not be resolved to a filesystem \ object and was not checked for aliasing", @@ -430,10 +466,30 @@ pub fn normalize_object_conflicts( Ok(Some(new_policy)) } +fn unresolvable_message(path: &str, intent: Intent, cause: &str, remedy: &str) -> String { + format!( + "Filesystem path '{path}' ({}) could not be resolved to a filesystem object ({cause}). \ + Because deniedPaths are present, MXC cannot verify it does not alias a denied object \ + and refuses to run rather than risk bypassing the deny. {remedy}", + intent.list_name(), + ) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn an_unexaminable_path_is_a_host_fault_but_denied_access_is_a_rejection() { + let host = FilesystemPolicyError::Host("stale mount".into()).into_response(); + assert_eq!(host.failure_phase, FailurePhase::LaunchFailed); + assert_eq!(host.exit_code, -1); + + let policy = FilesystemPolicyError::Policy("permission denied".into()).into_response(); + assert_eq!(policy.failure_phase, FailurePhase::Rejected); + assert_eq!(policy.exit_code, 1); + } + #[test] fn unknown_resolution_takes_precedence_over_absent() { assert_eq!( @@ -675,8 +731,9 @@ mod tests { let err = normalize_object_conflicts(&p, &mut logger) .expect_err("an unexaminable path with deniedPaths present must fail closed"); assert!( - err.contains("could not be resolved") && err.contains("deniedPaths"), - "expected a fail-closed rejection message, got: {err}" + matches!(&err, FilesystemPolicyError::Policy(message) + if message.contains("could not be resolved") && message.contains("deniedPaths")), + "expected a fail-closed policy rejection, got: {err:?}" ); // Restore traversal so the tempdir can recurse in during cleanup. @@ -742,8 +799,9 @@ mod tests { let err = normalize_object_conflicts(&p, &mut logger) .expect_err("an unexaminable path with deniedPaths present must fail closed"); assert!( - err.contains("could not be resolved") && err.contains("deniedPaths"), - "expected a fail-closed rejection message, got: {err}" + matches!(&err, FilesystemPolicyError::Policy(message) + if message.contains("could not be resolved") && message.contains("deniedPaths")), + "expected a fail-closed policy rejection, got: {err:?}" ); // Remove the deny ACE so the tempdir can clean up.