From 7329141bd241d51ddf31b51c03e6b2f6381886a9 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Mon, 5 Oct 2026 11:12:35 -0700 Subject: [PATCH 01/22] fix: route captured network decisions through Tessera Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e408b60b-e267-416c-806d-0a7e119fe53f --- .../common/base_container_helpers.rs | 16 ++++-- .../common/base_container_runner.rs | 54 +++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/mxc-sdk/src/backends/process_container/common/base_container_helpers.rs b/src/mxc-sdk/src/backends/process_container/common/base_container_helpers.rs index 1b60e3950..48982d22f 100644 --- a/src/mxc-sdk/src/backends/process_container/common/base_container_helpers.rs +++ b/src/mxc-sdk/src/backends/process_container/common/base_container_helpers.rs @@ -17,7 +17,7 @@ use crate::process_security_environment_spec::process_security_environment_layou }; use crate::process_container_common::network_policy_helpers::{ - add_default_network_capabilities, ensure_capability, + add_default_network_capabilities, ensure_capability, INTERNET_CLIENT_CAPABILITY, }; use crate::process_container_common::secenv::SecurityEnvironmentVersion; @@ -37,7 +37,7 @@ pub(super) fn build_psec_v1_security_environment_spec( "build_psec_v1_security_environment_spec only supports PSEC major version 1" ); let mut builder = flatbuffers::FlatBufferBuilder::with_capacity(1024); - let mut capabilities = effective_capabilities(&request.policy); + let mut capabilities = effective_capabilities(request); if request.policy.network_proxy.is_enabled() && unrestricted_host_loopback_allowed(&request.policy) { @@ -73,7 +73,8 @@ pub(super) fn build_psec_v1_security_environment_spec( builder.finished_data().to_vec() } -fn effective_capabilities(policy: &ContainerPolicy) -> Vec { +fn effective_capabilities(request: &ExecutionRequest) -> Vec { + let policy = &request.policy; let mut capabilities: Vec<_> = policy .capabilities .iter() @@ -81,6 +82,15 @@ fn effective_capabilities(policy: &ContainerPolicy) -> Vec { .cloned() .collect(); add_default_network_capabilities(policy, &mut capabilities); + if policy.capture_denials.is_some() + && policy.network_egress.is_some() + && !policy.network_proxy.is_enabled() + { + // Let outbound attempts pass the capability gate so Tessera's egress + // policy makes and records the decision. Proxy mode must remain direct- + // egress denied and therefore deliberately omits internetClient. + ensure_capability(&mut capabilities, INTERNET_CLIENT_CAPABILITY); + } capabilities } 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..a1b643dd2 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 @@ -2910,6 +2910,60 @@ mod tests { ); } + #[test] + fn capture_denials_directional_default_deny_reaches_tessera_network_policy() { + let mut request = ExecutionRequest::default(); + request.policy.capture_denials = Some(Default::default()); + request.policy.network_egress = Some(wxc_common::models::NetworkEgressPolicy { + default: NetworkAction::Deny, + ..Default::default() + }); + + let bytes = BaseContainerRunner::build_process_security_environment_spec(&request); + let spec = psec_layout::root_as_process_security_environment(&bytes).unwrap(); + let egress = spec + .network_policy() + .and_then(|policy| policy.egress()) + .expect("PSEC must carry the Tessera egress policy"); + + assert_eq!(spec.capabilities(), Some("internetClient")); + assert_eq!(egress.default_action(), psec_layout::FilterAction::deny); + } + + #[test] + fn capture_denials_proxy_preserves_proxy_capability_posture() { + let mut request = ExecutionRequest::default(); + request.policy.capture_denials = Some(Default::default()); + request.policy.runtime_network_proxy_specified = true; + request.policy.network_proxy = ProxyConfig { + address: Some(ProxyAddress::new("127.0.0.1".to_string(), 8080)), + builtin_test_server: false, + }; + request.policy.network_egress = Some(wxc_common::models::NetworkEgressPolicy { + default: NetworkAction::Deny, + ..Default::default() + }); + request.policy.network_ingress = Some(wxc_common::models::NetworkIngressPolicy { + default: NetworkAction::Allow, + host_loopback: NetworkAction::Allow, + }); + + let bytes = BaseContainerRunner::build_process_security_environment_spec(&request); + let spec = psec_layout::root_as_process_security_environment(&bytes).unwrap(); + + assert_eq!( + spec.capabilities(), + Some("privateNetworkClientServer,networkLoopback") + ); + assert!(spec + .capabilities() + .is_none_or(|capabilities| !capabilities.contains("internetClient"))); + assert!(spec + .network_policy() + .and_then(|network| network.proxy()) + .is_some()); + } + #[test] fn build_process_security_environment_spec_ignores_empty_capability() { let mut request = ExecutionRequest::default(); From cb560331b7d16905b986dc45d5338b1a164206d9 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Tue, 6 Oct 2026 13:54:18 -0700 Subject: [PATCH 02/22] fix: update ProcessContainer test paths after SDK consolidation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e408b60b-e267-416c-806d-0a7e119fe53f --- .../process_container/common/base_container_runner.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 a1b643dd2..9a138fcf1 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 @@ -2104,8 +2104,8 @@ mod tests { }; use crate::mxc_common::models::{ BaseProcessUiConfig, ClipboardPolicy, ContainerPolicy, NetworkAction, NetworkCidr, - NetworkEgressPolicy, NetworkPeer, NetworkPort, NetworkProtocol, NetworkRule, ProxyAddress, - ProxyConfig, UiPolicy, + NetworkEgressPolicy, NetworkIngressPolicy, NetworkPeer, NetworkPort, NetworkProtocol, + NetworkRule, ProxyAddress, ProxyConfig, UiPolicy, }; use crate::mxc_common::ui_policy::EffectiveUiRestrictions; use crate::process_container_common::job_object::to_job_object_uilimit_mask; @@ -2914,7 +2914,7 @@ mod tests { fn capture_denials_directional_default_deny_reaches_tessera_network_policy() { let mut request = ExecutionRequest::default(); request.policy.capture_denials = Some(Default::default()); - request.policy.network_egress = Some(wxc_common::models::NetworkEgressPolicy { + request.policy.network_egress = Some(NetworkEgressPolicy { default: NetworkAction::Deny, ..Default::default() }); @@ -2939,11 +2939,11 @@ mod tests { address: Some(ProxyAddress::new("127.0.0.1".to_string(), 8080)), builtin_test_server: false, }; - request.policy.network_egress = Some(wxc_common::models::NetworkEgressPolicy { + request.policy.network_egress = Some(NetworkEgressPolicy { default: NetworkAction::Deny, ..Default::default() }); - request.policy.network_ingress = Some(wxc_common::models::NetworkIngressPolicy { + request.policy.network_ingress = Some(NetworkIngressPolicy { default: NetworkAction::Allow, host_loopback: NetworkAction::Allow, }); From c26bae63901479f5c9ab0d7079e880f9392a3cd8 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Wed, 7 Oct 2026 11:35:26 -0700 Subject: [PATCH 03/22] test: align proxy fixture with current config Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e408b60b-e267-416c-806d-0a7e119fe53f --- .../backends/process_container/common/base_container_runner.rs | 1 - 1 file changed, 1 deletion(-) 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 9a138fcf1..f4cba3519 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 @@ -2937,7 +2937,6 @@ mod tests { request.policy.runtime_network_proxy_specified = true; request.policy.network_proxy = ProxyConfig { address: Some(ProxyAddress::new("127.0.0.1".to_string(), 8080)), - builtin_test_server: false, }; request.policy.network_egress = Some(NetworkEgressPolicy { default: NetworkAction::Deny, From 764d732572e10d167199af10bf93ec7c6f793857 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Thu, 8 Oct 2026 10:15:43 -0700 Subject: [PATCH 04/22] fix: emit complete WFP capture diagnostics Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e408b60b-e267-416c-806d-0a7e119fe53f --- .../common/base_container_runner.rs | 46 ++++++-- .../common/capture_output.rs | 103 ++++++++++++++++++ src/tools/wxc/src/audit.rs | 69 +++++++++--- 3 files changed, 194 insertions(+), 24 deletions(-) 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 f4cba3519..44ddd1f0e 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 @@ -13,7 +13,7 @@ use std::path::{Path, PathBuf}; use std::ptr; use std::sync::Arc; -use crate::learning_mode_core::DenialAnalyzer; +use crate::learning_mode_core::{capture_diagnostics::CaptureAnalysis, AnalyzeError}; use crate::learning_mode_windows::{EtlDenialAnalyzer, LearningModeApi, LearningModeError}; use windows::Win32::Foundation::{ CloseHandle, GetLastError, SetHandleInformation, ERROR_CALL_NOT_IMPLEMENTED, E_NOTIMPL, HANDLE, @@ -59,7 +59,7 @@ use crate::process_container_common::base_container_helpers::{ }; use crate::process_container_common::capture_output::{ combine_capture_and_cleanup_results, combine_process_and_teardown_results, - remove_internal_capture_file, unique_denials_output_paths, write_denials_document, + remove_internal_capture_file, unique_denials_output_paths, write_capture_denials_document, write_stderr_line_best_effort, }; use crate::process_container_common::job_object::UiJobObject; @@ -79,6 +79,16 @@ use windows::Win32::System::Threading::{ ResumeThread, CREATE_NO_WINDOW, CREATE_SUSPENDED, CREATE_UNICODE_ENVIRONMENT, }; +trait CaptureDenialAnalyzer { + fn analyze_capture(&self, source_path: &Path) -> Result; +} + +impl CaptureDenialAnalyzer for EtlDenialAnalyzer { + fn analyze_capture(&self, source_path: &Path) -> Result { + EtlDenialAnalyzer::analyze_capture(self, source_path) + } +} + /// Build the environment block handed to the contained child. /// /// Honors the three states of [`ExecutionRequest::env`]: @@ -1635,21 +1645,21 @@ impl BaseContainerSandboxProcess { /// Decodes a sealed capture into the JSON denials document at `output_path`. fn decode_and_write_denials( - analyzer: &dyn DenialAnalyzer, + analyzer: &dyn CaptureDenialAnalyzer, etl_path: &std::path::Path, output_path: &std::path::Path, exit_code: i32, ) -> std::io::Result { - let analysis = analyzer.analyze(etl_path).map_err(|error| { + let analysis = analyzer.analyze_capture(etl_path).map_err(|error| { std::io::Error::other(format!( "captureDenials failed to decode denials ETL: {error}" )) })?; - write_denials_document(analysis, exit_code, output_path) + write_capture_denials_document(analysis, exit_code, output_path) } fn decode_write_and_finalize( - analyzer: &dyn DenialAnalyzer, + analyzer: &dyn CaptureDenialAnalyzer, etl_path: &Path, etl_directory: Option<&Path>, output_path: &Path, @@ -2100,7 +2110,8 @@ fn managed_capture_output_path_in( mod tests { use super::*; use crate::learning_mode_core::{ - AccessType, AnalysisResult, AnalyzeError, DenialsDocument, DeniedResource, ResourceType, + capture_diagnostics::CaptureVerboseLoggingSummary, AccessType, AnalysisResult, + AnalyzeError, DenialsDocument, DeniedResource, ResourceType, }; use crate::mxc_common::models::{ BaseProcessUiConfig, ClipboardPolicy, ContainerPolicy, NetworkAction, NetworkCidr, @@ -2280,10 +2291,15 @@ mod tests { result: Result, } - impl DenialAnalyzer for FakeAnalyzer { - fn analyze(&self, _source_path: &Path) -> Result { + impl CaptureDenialAnalyzer for FakeAnalyzer { + fn analyze_capture(&self, _source_path: &Path) -> Result { match &self.result { - Ok(result) => Ok(result.clone()), + Ok(result) => Ok(CaptureAnalysis { + denials: result.denials.clone(), + denied_resources_truncated: result.denied_resources_truncated, + verbose_logging: result.verbose_logging.clone(), + network_verbose_logging: CaptureVerboseLoggingSummary::default(), + }), Err(message) => Err(AnalyzeError::Decode((*message).to_string())), } } @@ -2387,8 +2403,16 @@ mod tests { assert_eq!(metadata.exit_code, 7); assert_eq!(metadata.total_denials, 1); let document: DenialsDocument = - serde_json::from_slice(&std::fs::read(output_path).unwrap()).unwrap(); + serde_json::from_slice(&std::fs::read(&output_path).unwrap()).unwrap(); assert_eq!(document.denials.len(), 1); + let verbose_path = + crate::learning_mode_core::verbose_logging_sibling_path(&output_path).unwrap(); + let verbose: serde_json::Value = + serde_json::from_slice(&std::fs::read(verbose_path).unwrap()).unwrap(); + assert_eq!( + verbose["version"], + crate::learning_mode_core::capture_diagnostics::CAPTURE_VERBOSE_LOGGING_VERSION + ); } #[test] diff --git a/src/mxc-sdk/src/backends/process_container/common/capture_output.rs b/src/mxc-sdk/src/backends/process_container/common/capture_output.rs index 5e5be87d3..6591c0225 100644 --- a/src/mxc-sdk/src/backends/process_container/common/capture_output.rs +++ b/src/mxc-sdk/src/backends/process_container/common/capture_output.rs @@ -19,6 +19,7 @@ use std::path::{Path, PathBuf}; use crate::learning_mode_core::{ + capture_diagnostics::{write_capture_verbose_logging_document, CaptureAnalysis}, verbose_logging_sibling_path, write_document, write_paired_output_files, write_verbose_logging_document, AnalysisResult, DenialSummary, DenialsDocument, DenialsOutputPointer, ExistingOutputPolicy, VerboseLoggingDocument, @@ -90,6 +91,41 @@ pub fn write_denials_document( }) } +/// Writes native capture analysis with the complete internal v5 diagnostics. +pub(crate) fn write_capture_denials_document( + analysis: CaptureAnalysis, + exit_code: i32, + output_path: &Path, +) -> std::io::Result { + let verbose_logging_path = verbose_logging_output_path(output_path)?; + let verbose_logging_document = analysis.verbose_document(); + let summary = DenialSummary::new( + exit_code, + analysis.denials.len(), + analysis.denied_resources_truncated, + ); + let document = DenialsDocument::new(analysis.denials, summary); + + write_paired_output_files( + "captureDenials", + output_path, + &verbose_logging_path, + ExistingOutputPolicy::CreateNew, + |writer| write_document(writer, &document), + |writer| write_capture_verbose_logging_document(writer, &verbose_logging_document), + )?; + + let pointer = DenialsOutputPointer::new(output_path.to_string_lossy(), &document.summary); + Ok(CaptureDenialsOutput { + kind: pointer.kind, + output_path: pointer.output_path, + exit_code: pointer.exit_code, + total_denials: pointer.total_denials, + denied_resources_truncated: pointer.denied_resources_truncated, + etl_path: None, + }) +} + /// Derives the deterministic verbose logging sibling path for a denials output. pub fn verbose_logging_output_path(output_path: &Path) -> std::io::Result { verbose_logging_sibling_path(output_path) @@ -281,6 +317,11 @@ pub fn write_stderr_line_best_effort(message: std::fmt::Arguments<'_>) { #[cfg(test)] mod tests { use super::*; + use crate::learning_mode_core::capture_diagnostics::{ + CaptureVerboseLoggingOutcomeReason, CaptureVerboseLoggingProvider, + CaptureVerboseLoggingSignature, CaptureVerboseLoggingSummary, ConfigurationRecommendation, + NetworkDecisionReason, NetworkEndpoint, CAPTURE_VERBOSE_LOGGING_VERSION, + }; use crate::learning_mode_core::{ AccessType, DeniedResource, ResourceType, VerboseLoggingOutcomeReason, VerboseLoggingProvider, VerboseLoggingSignature, @@ -330,6 +371,68 @@ mod tests { assert!(verbose_logging_output_path(&output_path).unwrap().exists()); } + #[test] + fn native_capture_writes_version_five_reason_specific_guidance() { + let directory = tempfile::tempdir().expect("temp directory"); + let output_path = directory.path().join("denials.json"); + let mut network_verbose_logging = CaptureVerboseLoggingSummary::default(); + let mut retained_bytes = 0; + network_verbose_logging.record_with_byte_budget( + CaptureVerboseLoggingSignature { + provider: CaptureVerboseLoggingProvider::LearningModeNetworkDecision, + provider_guid: "{71237669-21C3-4101-BD2F-FF38945D725A}".to_string(), + event_id: 1, + event_name: Some("NetworkDecisionV1".to_string()), + reason: CaptureVerboseLoggingOutcomeReason::Actionable, + pid: 0, + access_type: Some(AccessType::Unknown), + resource_type: Some(ResourceType::Network), + network_decision_reason: Some(NetworkDecisionReason::DirectDefaultDeny), + configuration_recommendation: Some(ConfigurationRecommendation::AddEgressAllow), + network_endpoint: Some(NetworkEndpoint { + protocol: "tcp".to_string(), + remote_address: "203.0.113.10".to_string(), + remote_port: Some(443), + }), + properties: Vec::new(), + }, + &mut retained_bytes, + crate::learning_mode_core::MAX_VERBOSE_LOGGING_SIGNATURE_BYTES, + ); + let analysis = CaptureAnalysis { + denials: vec![DeniedResource { + resource: "tcp://203.0.113.10:443".to_string(), + resource_type: ResourceType::Network, + access_type: AccessType::Unknown, + pid: 0, + filetime: 99, + }], + denied_resources_truncated: false, + verbose_logging: Default::default(), + network_verbose_logging, + }; + + write_capture_denials_document(analysis, 0, &output_path).unwrap(); + + let verbose: serde_json::Value = serde_json::from_slice( + &std::fs::read(verbose_logging_output_path(&output_path).unwrap()).unwrap(), + ) + .unwrap(); + assert_eq!(verbose["version"], CAPTURE_VERBOSE_LOGGING_VERSION); + assert_eq!( + verbose["signatures"][0]["signature"]["networkDecisionReason"], + "directDefaultDeny" + ); + assert_eq!( + verbose["signatures"][0]["signature"]["configurationRecommendation"], + "addEgressAllow" + ); + assert_eq!( + verbose["signatures"][0]["signature"]["networkEndpoint"]["remoteAddress"], + "203.0.113.10" + ); + } + #[test] fn write_denials_document_writes_verbose_logging_aggregates() { let directory = tempfile::tempdir().expect("temp directory"); diff --git a/src/tools/wxc/src/audit.rs b/src/tools/wxc/src/audit.rs index 9ea638853..ca4ea9fcc 100644 --- a/src/tools/wxc/src/audit.rs +++ b/src/tools/wxc/src/audit.rs @@ -108,19 +108,21 @@ pub fn finalize( ) })?; validate_metadata(capture, &document)?; - let _: mxc_sdk::learning_mode_core::VerboseLoggingDocument = - serde_json::from_slice(&std::fs::read(&source_verbose_logging).map_err(|error| { - format!( - "failed to read captureDenials verbose logging output {}: {error}", - source_verbose_logging.display() - ) - })?) - .map_err(|error| { - format!( - "captureDenials verbose logging output {} is not valid JSON: {error}", - source_verbose_logging.display() - ) - })?; + let verbose_logging_bytes = std::fs::read(&source_verbose_logging).map_err(|error| { + format!( + "failed to read captureDenials verbose logging output {}: {error}", + source_verbose_logging.display() + ) + })?; + mxc_sdk::learning_mode_core::verbose_logging::validate_verbose_logging_document( + &verbose_logging_bytes, + ) + .map_err(|error| { + format!( + "captureDenials verbose logging output {} is not valid JSON: {error}", + source_verbose_logging.display() + ) + })?; let final_denials = context.log_dir.join("denials.json"); let final_verbose_logging = mxc_sdk::learning_mode_core::verbose_logging_sibling_path( @@ -372,6 +374,47 @@ mod tests { ); } + #[test] + fn finalize_accepts_version_five_verbose_logging() { + let directory = tempfile::tempdir().unwrap(); + let log_dir = directory.path().join("audit"); + std::fs::create_dir_all(&log_dir).unwrap(); + let source_denials = log_dir.join("denials.unique.json"); + let source_etl = log_dir.join("denials.unique.etl"); + let document = DenialsDocument::new(Vec::new(), DenialSummary::new(0, 0, false)); + std::fs::write(&source_denials, serde_json::to_vec(&document).unwrap()).unwrap(); + std::fs::write(&source_etl, b"etl").unwrap(); + let mut response = response_with_capture(&source_denials, &source_etl, 0); + let source_verbose_logging = + mxc_sdk::learning_mode_core::verbose_logging_sibling_path(&source_denials).unwrap(); + std::fs::write( + &source_verbose_logging, + br#"{ + "version": 5, + "signatures": [], + "summary": { + "totalOccurrences": 0, + "overflowOccurrences": 0, + "actionableOverflowOccurrences": 0, + "aggregateGroupsTruncated": false, + "processedEventsTruncated": false, + "actionableLimitReached": false + } + }"#, + ) + .unwrap(); + let context = AuditContext { + log_dir: log_dir.clone(), + config_path: None, + }; + + finalize(&mut response, &context, directory.path(), false).unwrap(); + + assert!(!source_verbose_logging.exists()); + let published = std::fs::read_to_string(log_dir.join("denials.verbose.json")).unwrap(); + assert!(published.contains("\"version\": 5")); + } + #[test] fn finalize_updates_metadata_before_postprocessing_failure() { let directory = tempfile::tempdir().unwrap(); From cc9cbff965c4f185605142b525ebc7467e87b7aa Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Thu, 8 Oct 2026 10:34:09 -0700 Subject: [PATCH 05/22] fix: require authored egress for capture capability Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e408b60b-e267-416c-806d-0a7e119fe53f --- .../common/base_container_runner.rs | 78 ++++++++++++++++++- src/mxc-sdk/src/core/mxc_common/mod.rs | 2 +- .../src/core/mxc_common/network_parser.rs | 12 +-- 3 files changed, 79 insertions(+), 13 deletions(-) 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 44ddd1f0e..12d23af6e 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 @@ -2114,11 +2114,13 @@ mod tests { AnalyzeError, DenialsDocument, DeniedResource, ResourceType, }; use crate::mxc_common::models::{ - BaseProcessUiConfig, ClipboardPolicy, ContainerPolicy, NetworkAction, NetworkCidr, - NetworkEgressPolicy, NetworkIngressPolicy, NetworkPeer, NetworkPort, NetworkProtocol, - NetworkRule, ProxyAddress, ProxyConfig, UiPolicy, + BaseProcessUiConfig, ClipboardPolicy, ContainerPolicy, ContainmentBackend, NetworkAction, + NetworkCidr, NetworkEgressPolicy, NetworkIngressPolicy, NetworkPeer, NetworkPort, + NetworkProtocol, NetworkRule, ProxyAddress, ProxyConfig, UiPolicy, }; + use crate::mxc_common::network_parser::{parse_network_policy, NetworkSections}; use crate::mxc_common::ui_policy::EffectiveUiRestrictions; + use crate::mxc_common::wire; use crate::process_container_common::job_object::to_job_object_uilimit_mask; use crate::process_security_environment_spec::process_security_environment_layout as psec_layout; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -2954,6 +2956,76 @@ mod tests { assert_eq!(egress.default_action(), psec_layout::FilterAction::deny); } + #[test] + fn capture_capability_requires_an_explicit_direct_egress_section() { + for (network, expected_capabilities) in [ + (None, None), + ( + Some(wire::Network { + egress: None, + ingress: Some(wire::NetworkIngress { + default: Some(wire::NetworkAction::Deny), + host_loopback: Some(wire::NetworkAction::Deny), + }), + }), + None, + ), + ( + Some(wire::Network { + egress: Some(wire::NetworkEgress { + default: Some(wire::NetworkAction::Deny), + allow: None, + deny: None, + }), + ingress: None, + }), + Some("internetClient"), + ), + ] { + let mut request = ExecutionRequest::default(); + request.policy.capture_denials = Some(Default::default()); + parse_network_policy( + &mut request.policy, + NetworkSections { + network, + runtime: None, + process_container: None, + }, + &ContainmentBackend::ProcessContainer, + ) + .unwrap(); + + let bytes = BaseContainerRunner::build_process_security_environment_spec(&request); + let spec = psec_layout::root_as_process_security_environment(&bytes).unwrap(); + let egress = spec + .network_policy() + .and_then(|policy| policy.egress()) + .expect("PSEC must retain deny-default egress"); + + assert_eq!(spec.capabilities(), expected_capabilities); + assert_eq!(egress.default_action(), psec_layout::FilterAction::deny); + } + } + + #[test] + fn directional_default_deny_without_capture_does_not_add_internet_capability() { + let mut request = ExecutionRequest::default(); + request.policy.network_egress = Some(NetworkEgressPolicy { + default: NetworkAction::Deny, + ..Default::default() + }); + + let bytes = BaseContainerRunner::build_process_security_environment_spec(&request); + let spec = psec_layout::root_as_process_security_environment(&bytes).unwrap(); + let egress = spec + .network_policy() + .and_then(|policy| policy.egress()) + .expect("PSEC must retain deny-default egress"); + + assert!(spec.capabilities().is_none()); + assert_eq!(egress.default_action(), psec_layout::FilterAction::deny); + } + #[test] fn capture_denials_proxy_preserves_proxy_capability_posture() { let mut request = ExecutionRequest::default(); diff --git a/src/mxc-sdk/src/core/mxc_common/mod.rs b/src/mxc-sdk/src/core/mxc_common/mod.rs index abe02c2c2..1ae338a35 100644 --- a/src/mxc-sdk/src/core/mxc_common/mod.rs +++ b/src/mxc-sdk/src/core/mxc_common/mod.rs @@ -25,7 +25,7 @@ pub mod microvm_staging; pub mod models; pub mod mxc_error; pub mod network_blocks; -mod network_parser; +pub(crate) mod network_parser; pub mod policy_identity; pub use network_parser::host_is_canonical_loopback; pub mod proxy_env; diff --git a/src/mxc-sdk/src/core/mxc_common/network_parser.rs b/src/mxc-sdk/src/core/mxc_common/network_parser.rs index 12d1d9288..463de97be 100644 --- a/src/mxc-sdk/src/core/mxc_common/network_parser.rs +++ b/src/mxc-sdk/src/core/mxc_common/network_parser.rs @@ -66,12 +66,7 @@ fn convert_wire_proxy_at(url_str: &str, path: &str) -> Result) -> Result { - let egress = egress.unwrap_or(wire::NetworkEgress { - default: None, - allow: None, - deny: None, - }); +fn convert_egress(egress: wire::NetworkEgress) -> Result { Ok(NetworkEgressPolicy { default: egress.default.map(convert_action).unwrap_or_default(), allow: convert_rules(egress.allow.unwrap_or_default(), "network.egress.allow")?, @@ -119,14 +114,13 @@ pub(crate) fn parse_network_policy( // metadata is intentionally excluded because it may be supplied at exec. policy.network_mode_specified = has_directional_policy_fields(&network); - let egress = convert_egress(network.egress)?; - policy.network_egress = Some(egress); + policy.network_egress = network.egress.map(convert_egress).transpose()?; let ingress = convert_ingress(network.ingress); policy.network_ingress = Some(ingress); } None => { - policy.network_egress = Some(NetworkEgressPolicy::default()); + policy.network_egress = None; policy.network_ingress = Some(NetworkIngressPolicy::default()); } } From 4ce404785792e4dca4dceab957d1a7fb0edf3970 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Thu, 8 Oct 2026 11:10:12 -0700 Subject: [PATCH 06/22] fix: gate capture network capability Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e408b60b-e267-416c-806d-0a7e119fe53f --- .../common/base_container_helpers.rs | 9 ++- .../common/base_container_helpers/tests.rs | 24 +++++-- .../common/base_container_runner.rs | 68 ++++++++++++++++++- 3 files changed, 91 insertions(+), 10 deletions(-) diff --git a/src/mxc-sdk/src/backends/process_container/common/base_container_helpers.rs b/src/mxc-sdk/src/backends/process_container/common/base_container_helpers.rs index 48982d22f..9b955d883 100644 --- a/src/mxc-sdk/src/backends/process_container/common/base_container_helpers.rs +++ b/src/mxc-sdk/src/backends/process_container/common/base_container_helpers.rs @@ -31,13 +31,14 @@ pub(super) fn build_psec_v1_security_environment_spec( request: &ExecutionRequest, version: SecurityEnvironmentVersion, supports_network_ingress: bool, + capture_network_decisions: bool, ) -> Vec { assert_eq!( version.major, 1, "build_psec_v1_security_environment_spec only supports PSEC major version 1" ); let mut builder = flatbuffers::FlatBufferBuilder::with_capacity(1024); - let mut capabilities = effective_capabilities(request); + let mut capabilities = effective_capabilities(request, capture_network_decisions); if request.policy.network_proxy.is_enabled() && unrestricted_host_loopback_allowed(&request.policy) { @@ -73,7 +74,10 @@ pub(super) fn build_psec_v1_security_environment_spec( builder.finished_data().to_vec() } -fn effective_capabilities(request: &ExecutionRequest) -> Vec { +fn effective_capabilities( + request: &ExecutionRequest, + capture_network_decisions: bool, +) -> Vec { let policy = &request.policy; let mut capabilities: Vec<_> = policy .capabilities @@ -83,6 +87,7 @@ fn effective_capabilities(request: &ExecutionRequest) -> Vec { .collect(); add_default_network_capabilities(policy, &mut capabilities); if policy.capture_denials.is_some() + && capture_network_decisions && policy.network_egress.is_some() && !policy.network_proxy.is_enabled() { diff --git a/src/mxc-sdk/src/backends/process_container/common/base_container_helpers/tests.rs b/src/mxc-sdk/src/backends/process_container/common/base_container_helpers/tests.rs index 46259a23d..c1ebce1bd 100644 --- a/src/mxc-sdk/src/backends/process_container/common/base_container_helpers/tests.rs +++ b/src/mxc-sdk/src/backends/process_container/common/base_container_helpers/tests.rs @@ -53,8 +53,12 @@ fn runtime_proxy_request(proxy: &TcpListener) -> ExecutionRequest { fn runtime_proxy_uses_peer_and_capability_without_native_ingress() { let proxy = TcpListener::bind(("127.0.0.1", 0)).unwrap(); let request = runtime_proxy_request(&proxy); - let bytes = - build_psec_v1_security_environment_spec(&request, SecurityEnvironmentVersion::V1_1, true); + let bytes = build_psec_v1_security_environment_spec( + &request, + SecurityEnvironmentVersion::V1_1, + true, + false, + ); let spec = psec_layout::root_as_process_security_environment(&bytes).unwrap(); let network = spec.network_policy().expect("network policy"); let proxy_url = format!("http://{}", proxy.local_addr().unwrap()); @@ -87,8 +91,12 @@ fn runtime_proxy_creates_native_security_environment() { } let proxy = TcpListener::bind(("127.0.0.1", 0)).unwrap(); let request = runtime_proxy_request(&proxy); - let bytes = - build_psec_v1_security_environment_spec(&request, SecurityEnvironmentVersion::V1_1, true); + let bytes = build_psec_v1_security_environment_spec( + &request, + SecurityEnvironmentVersion::V1_1, + true, + false, + ); let environment = create(&bytes, PROCESS_SECURITY_ENVIRONMENT_FLAG_NONE).unwrap_or_else(|error| { panic!("valid runtime proxy policy failed to create PSEC: {error}") @@ -107,8 +115,12 @@ fn identity_scoped_proxy_does_not_grant_host_loopback() { .unwrap() .host_loopback = NetworkAction::Deny; request.policy.allowed_proxy_peer = Some("Contoso.Proxy_12345".to_string()); - let bytes = - build_psec_v1_security_environment_spec(&request, SecurityEnvironmentVersion::V1_0, false); + let bytes = build_psec_v1_security_environment_spec( + &request, + SecurityEnvironmentVersion::V1_0, + false, + false, + ); let spec = psec_layout::root_as_process_security_environment(&bytes).unwrap(); let network = spec.network_policy().unwrap(); 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 12d23af6e..b0ec8c136 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 @@ -14,7 +14,9 @@ use std::ptr; use std::sync::Arc; use crate::learning_mode_core::{capture_diagnostics::CaptureAnalysis, AnalyzeError}; -use crate::learning_mode_windows::{EtlDenialAnalyzer, LearningModeApi, LearningModeError}; +use crate::learning_mode_windows::{ + EtlDenialAnalyzer, LearningModeApi, LearningModeError, LearningModeTraceSources, +}; use windows::Win32::Foundation::{ CloseHandle, GetLastError, SetHandleInformation, ERROR_CALL_NOT_IMPLEMENTED, E_NOTIMPL, HANDLE, HANDLE_FLAG_INHERIT, WAIT_OBJECT_0, WAIT_TIMEOUT, @@ -216,6 +218,10 @@ impl CaptureSessionOps for CaptureSession { } trait CaptureSessionFactory: Send + Sync { + fn trace_sources( + &self, + ) -> Result; + fn begin( &self, sandbox_specification: &[u8], @@ -226,6 +232,12 @@ trait CaptureSessionFactory: Send + Sync { struct RealCaptureSessionFactory; impl CaptureSessionFactory for RealCaptureSessionFactory { + fn trace_sources( + &self, + ) -> Result { + LearningModeApi::load().map(|api| api.trace_sources()) + } + fn begin( &self, sandbox_specification: &[u8], @@ -296,6 +308,7 @@ impl BaseContainerRunner { request, version, version >= SecurityEnvironmentVersion::V1_1, + request.policy.capture_denials.is_some(), ) } @@ -558,10 +571,23 @@ impl BaseContainerRunner { let supports_network_ingress = psec_version >= SecurityEnvironmentVersion::V1_1 && secenv::query_support(SecurityEnvironmentSupport::NetworkIngress); + let capture_network_decisions = match capture_denials.as_ref() { + Some(_) => self + .capture_factory + .trace_sources() + .map_err(|error| { + ScriptResponse::error(&format!( + "captureDenials: failed to inspect learning-mode trace sources: {error}" + )) + })? + .includes_network(), + None => false, + }; let process_security_environment_spec = build_psec_v1_security_environment_spec( request, psec_version, supports_network_ingress, + capture_network_decisions, ); let _ = writeln!( logger, @@ -2254,6 +2280,7 @@ mod tests { } struct FakeCaptureFactory { + trace_sources: LearningModeTraceSources, begin_error: Option<(&'static str, i32)>, finish_error: Option<(&'static str, i32)>, begin_calls: AtomicUsize, @@ -2261,6 +2288,13 @@ mod tests { } impl CaptureSessionFactory for FakeCaptureFactory { + fn trace_sources( + &self, + ) -> Result + { + Ok(self.trace_sources) + } + fn begin( &self, _sandbox_specification: &[u8], @@ -2282,6 +2316,7 @@ mod tests { fn fake_capture_factory() -> Arc { Arc::new(FakeCaptureFactory { + trace_sources: LearningModeTraceSources::AccessAndNetwork, begin_error: None, finish_error: None, begin_calls: AtomicUsize::new(0), @@ -2672,6 +2707,7 @@ mod tests { #[test] fn capture_factory_injects_begin_failure() { let factory = Arc::new(FakeCaptureFactory { + trace_sources: LearningModeTraceSources::AccessAndNetwork, begin_error: Some(( "StartLearningModeTrace", windows::Win32::Foundation::E_FAIL.0, @@ -2698,6 +2734,7 @@ mod tests { #[test] fn capture_factory_injects_finish_failure_once() { let factory = Arc::new(FakeCaptureFactory { + trace_sources: LearningModeTraceSources::AccessAndNetwork, begin_error: None, finish_error: Some(( "StopLearningModeTrace", @@ -2956,6 +2993,30 @@ mod tests { assert_eq!(egress.default_action(), psec_layout::FilterAction::deny); } + #[test] + fn capture_capability_requires_a_network_aware_trace() { + let mut request = ExecutionRequest::default(); + request.policy.capture_denials = Some(Default::default()); + request.policy.network_egress = Some(NetworkEgressPolicy { + default: NetworkAction::Deny, + ..Default::default() + }); + + for (capture_network_decisions, expected_capabilities) in + [(false, None), (true, Some("internetClient"))] + { + let bytes = build_psec_v1_security_environment_spec( + &request, + SecurityEnvironmentVersion::V1_0, + false, + capture_network_decisions, + ); + let spec = psec_layout::root_as_process_security_environment(&bytes).unwrap(); + + assert_eq!(spec.capabilities(), expected_capabilities); + } + } + #[test] fn capture_capability_requires_an_explicit_direct_egress_section() { for (network, expected_capabilities) in [ @@ -3329,7 +3390,7 @@ mod tests { let version = BaseContainerRunner::choose_min_required_psec_version_for_request(&request, true); assert_eq!(version, SecurityEnvironmentVersion::V1_0); - let bytes = build_psec_v1_security_environment_spec(&request, version, false); + let bytes = build_psec_v1_security_environment_spec(&request, version, false, false); let spec = psec_layout::root_as_process_security_environment(&bytes).unwrap(); let network = spec.network_policy().unwrap(); assert_eq!(spec.version().minor(), 0); @@ -3369,6 +3430,7 @@ mod tests { &ExecutionRequest::default(), SecurityEnvironmentVersion { major: 2, minor: 0 }, false, + false, ); } @@ -3379,6 +3441,7 @@ mod tests { &request, SecurityEnvironmentVersion::V1_1, false, + false, ); let spec = psec_layout::root_as_process_security_environment(&bytes).unwrap(); @@ -3399,6 +3462,7 @@ mod tests { &request, SecurityEnvironmentVersion::V1_0, false, + false, ); let spec = psec_layout::root_as_process_security_environment(&bytes).unwrap(); let network = spec.network_policy().expect("network policy"); From 1b5872f13afa47f250df9e8a23b1ef624fe08d28 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Mon, 5 Oct 2026 11:13:27 -0700 Subject: [PATCH 07/22] docs: describe WFP denial capture Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e408b60b-e267-416c-806d-0a7e119fe53f --- .../process-container/os-version-support.md | 22 +++++ docs/logging-access-denied.md | 85 ++++++++++++++++--- 2 files changed, 95 insertions(+), 12 deletions(-) diff --git a/docs/backends/process-container/os-version-support.md b/docs/backends/process-container/os-version-support.md index b5894f50c..b40a25d82 100644 --- a/docs/backends/process-container/os-version-support.md +++ b/docs/backends/process-container/os-version-support.md @@ -10,3 +10,25 @@ This is the reference for **Windows OS support of the `processcontainer` and `is | 25H2 | [26200.9278](https://support.microsoft.com/en-us/servicing/os/windows-11/2026/08/kb5120998-windows-11-24h2-25h2-update) | [26200.9550](https://support.microsoft.com/en-us/servicing/os/windows-11/2026/09/kb5124010-windows-11-24h2-25h2-update) | | 26H2 | [26300.9550](https://support.microsoft.com/en-us/servicing/os/windows-11/2026/09/kb5124010-windows-11-24h2-25h2-update) | [26300.9550](https://support.microsoft.com/en-us/servicing/os/windows-11/2026/09/kb5124010-windows-11-24h2-25h2-update) | | 26H1 | [28000.2804](https://support.microsoft.com/en-us/servicing/os/windows-11/2026/08/kb5120996-windows-11-26h1-update) | [28000.3086](https://support.microsoft.com/en-us/servicing/os/windows-11/2026/09/kb5124006-windows-11-26h1-update) | + +## Learning Mode capture compatibility + +`processContainer.captureDenials` selects its capture provider by runtime +capability probing rather than by the release table above. Native +ProcessContainer capture requires the PSEC create/query/close exports plus one +compatible Learning Mode start export and both lifecycle exports: + +- `StartLearningModeTraceWithOptions` (preferred) or `StartLearningModeTrace` +- `StopLearningModeTrace` +- `CloseLearningModeTrace` + +The option-aware start collects access and network events. The legacy start +collects access events only, so its trace cannot contain WFP network decisions. +If the selected option-aware call fails, MXC reports that failure and does not +retry through the legacy export. When native capture is unavailable, MXC uses +the guarded WPR provider only when the selected AppContainer tier and helper can +fully honor the request. + +Internal validation found no native capture support on build `26657.1002` and +the complete option-aware contract on build `26663.1000`. These are validation +points, not public support boundaries; runtime probing remains authoritative. diff --git a/docs/logging-access-denied.md b/docs/logging-access-denied.md index 309c656c8..3f50d56d0 100644 --- a/docs/logging-access-denied.md +++ b/docs/logging-access-denied.md @@ -103,10 +103,10 @@ stays enforced: `captureDenials.mode: "allow"` for permissive application-driven capture. It is a compatibility wrapper over `captureDenials.mode: "allow"` with ETL retention forced on, and injects `permissiveLearningMode`. The selected - ProcessContainer capture backend owns the trace lifecycle: complete PSEC/V2 - hosts use native capture without PLM or UAC, while legacy or incompatible - tiers use the session-scoped guarded-WPR fallback and elevate only its - fixed-operation guardian. The CLI consumes the returned JSON and ETL paths, + ProcessContainer capture backend owns the trace lifecycle: compatible PSEC + hosts use native capture without PLM or UAC, while incompatible tiers use + the session-scoped guarded-WPR fallback and elevate only its fixed-operation + guardian. The CLI consumes the returned JSON and ETL paths, relocates the policy output, its verbose logging sibling, and the trace to `denials.json`, `denials.verbose.json`, and `trace.etl`, and generates the source snapshot and `Adjusted_*.json` from the policy denials without decoding @@ -136,13 +136,17 @@ Windows-only `captureDenials` config switch drives collecting those events and surfacing the resulting denials to the caller. Its `mode` selects how each ungranted access is handled while it is recorded: -> **Host selection.** MXC prefers native capture on a feature-enabled Windows -> build exposing the complete official V2 API set: -> `StartLearningModeTrace`, `StopLearningModeTrace`, +> **Host selection.** MXC uses native capture when Windows exposes the PSEC +> lifecycle plus a compatible Learning Mode lifecycle. It prefers +> `StartLearningModeTraceWithOptions`, which requests `ACCESS | NETWORK`, and +> falls back to access-only `StartLearningModeTrace` when the option-aware +> export is absent. Both paths require `StopLearningModeTrace`, > `CloseLearningModeTrace`, `CreateProcessSecurityEnvironment`, > `QueryProcessSecurityEnvironmentSupport`, and -> `CloseProcessSecurityEnvironment`. When that set is unavailable or cannot -> fully honor the requested policy, MXC retains the highest compatible legacy +> `CloseProcessSecurityEnvironment`. If the option-aware export exists but its +> call fails, MXC reports the failure rather than silently downgrading. When +> native capture is unavailable or cannot fully honor the requested policy, +> MXC retains the highest compatible legacy > AppContainer containment tier (AppContainer+BFS or AppContainer+DACL) and pairs it > with the guarded WPR capture provider. Unsupported hosts return > `backend_unavailable` only when neither path can preserve the full policy. @@ -229,6 +233,63 @@ sandbox policy: - `filetime` is a decimal string containing the Windows `FILETIME` value, so JavaScript consumers retain all 64 bits without numeric precision loss. +### Network denial sources + +Feature-enabled Windows builds can add WFP decisions to the managed Learning +Mode ETL through the manifested +`Microsoft-Windows-LearningMode-NetworkDecision` provider +(`{71237669-21C3-4101-BD2F-FF38945D725A}`). MXC accepts event ID `1`, +`NetworkDecisionV1`, with schema version `1`. The OS Learning Mode broker owns +the WFP subscription, runtime-filter lookup, subject scoping, event +normalization, queue draining, and ETW flush before the trace is sealed. +MXC requests both `ACCESS` and `NETWORK` sources when it starts native capture; +a failed combined start fails the trace rather than retrying with partial +access-only collection. MXC does not coordinate with WFP directly. + +MXC currently recognizes two normalized source domains: + +- App Isolation missing-capability decisions map capability IDs `0`, `1`, and + `2` to `internetClient`, `internetClientServer`, and + `privateNetworkClientServer`. +- Tessera direct-network default-deny decisions map a complete remote endpoint + to a `network` resource such as `tcp://203.0.113.10:443` or + `udp://[2001:db8::1]:53`. + +`NetworkDecisionV1` retains its original 24-property event ID/version `1` +schema. Tessera attribution is carried only in the existing `Reason` field: + +| Reason | Meaning | +|---:|---| +| `100` | Direct default deny | +| `101` | Authored explicit deny | +| `102` | Exclusion from an allow rule | +| `103` | Proxy-containment baseline | + +The internal WFP provider-data format used to produce the reason is not part of +the ETW contract. MXC does not parse provider data or require additional policy +model, rule-kind, or rule-ordinal fields. + +Tessera explicit denies, allow exclusions, and proxy-containment decisions are +intentional authored policy rather than missing grants. They are retained in +the verbose logging artifact but are not emitted as policy recommendations; +recommending a direct allow for proxy containment could bypass the proxy. +Malformed events, unknown reasons, identity mismatches, and incomplete +endpoints are also verbose-only. +Reason `65535` remains `unknownNetworkReason`. + +Actionable network records include an additive `details` object with +`kind: "network"` and the normalized source, reason, direction, protocol, +endpoint, application ID, and runtime filter ID. The WFP event does not provide +a reliable workload PID, so these records use `pid: 0`; the broker-provided +package, user, and application identities remain available to the decoder for +validation and diagnostics. `filetime` is the original WFP event timestamp +carried in the normalized payload, not the later ETW emission time. + +This source is available only through native managed broker capture. The +guarded-WPR fallback filters ETW by exact workload process generations, while +the normalized network event's ETW header identifies the broker process, so +guarded-WPR analysis intentionally excludes it. + ### Verbose logging event signatures Every successful decode also writes a deterministic sibling file: @@ -370,13 +431,13 @@ so CLI callers can locate the deliverable without scanning the filesystem: The pointer echoes the policy file's `summary`; that file is the authoritative record of denials. In-process Rust callers receive the same summary information through `Output::output_metadata` or `Sandbox::output_metadata()` after waiting. The -C# SDK exposes `ExecutionMetadata` through `ExecutionResult.OutputMetadata` and -`MxcProcess.OutputMetadata`. +C# SDK exposes it through `RunResult.OutputMetadata` and +`MxcSandboxProcess.OutputMetadata`. By default, the intermediate ETW `.etl` trace is an internal, runner-managed file in a protected per-run temporary directory that MXC deletes after analysis. Set `captureDenials.retainEtl` to `true` to preserve the sealed trace -for diagnostics after a terminal wait. Both native PSEC/V2 capture and the +for diagnostics after a terminal wait. Both native PSEC capture and the guarded-WPR fallback honor retention. Native retention begins under `%LOCALAPPDATA%\Microsoft\MXC\capture-denials\working` and moves to a protected per-run directory under `capture-denials\retained` only after sealing From de994b6c838a12a716c8b5d25c7785ebe3a82c0f Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Mon, 5 Oct 2026 16:50:35 -0700 Subject: [PATCH 08/22] docs: clarify network denial contract Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e408b60b-e267-416c-806d-0a7e119fe53f --- docs/logging-access-denied.md | 45 +++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/docs/logging-access-denied.md b/docs/logging-access-denied.md index 3f50d56d0..5283cdd54 100644 --- a/docs/logging-access-denied.md +++ b/docs/logging-access-denied.md @@ -242,9 +242,10 @@ Mode ETL through the manifested `NetworkDecisionV1`, with schema version `1`. The OS Learning Mode broker owns the WFP subscription, runtime-filter lookup, subject scoping, event normalization, queue draining, and ETW flush before the trace is sealed. -MXC requests both `ACCESS` and `NETWORK` sources when it starts native capture; -a failed combined start fails the trace rather than retrying with partial -access-only collection. MXC does not coordinate with WFP directly. +When option-aware native capture is selected, MXC requests both `ACCESS` and +`NETWORK` sources. A failed combined start fails the trace rather than retrying +with partial access-only collection. Legacy native capture remains access-only. +MXC does not coordinate with WFP directly. MXC currently recognizes two normalized source domains: @@ -285,10 +286,40 @@ package, user, and application identities remain available to the decoder for validation and diagnostics. `filetime` is the original WFP event timestamp carried in the normalized payload, not the later ETW emission time. -This source is available only through native managed broker capture. The -guarded-WPR fallback filters ETW by exact workload process generations, while -the normalized network event's ETW header identifies the broker process, so -guarded-WPR analysis intentionally excludes it. +The current caller-facing network details contract is: + +```json +{ + "resource": "tcp://203.0.113.10:443", + "resourceType": "network", + "accessType": "unknown", + "pid": 0, + "filetime": "132847890123512345", + "details": { + "kind": "network", + "source": "processContainerNetworkPolicy", + "reason": "directDefaultDeny", + "direction": "outbound", + "protocol": 6, + "remoteAddress": "203.0.113.10", + "remotePort": 443, + "applicationId": "\\Device\\HarddiskVolume3\\app.exe", + "filterId": "9001" + } +} +``` + +`source` is currently `processContainerNetworkPolicy`; the internal Tessera +component name is not exposed in the public JSON. The actionable reason is +`directDefaultDeny`. Direction is `outbound`, `inbound`, or `unknown`. +`filterId` is a decimal string so JavaScript consumers retain all 64 bits. +Optional protocol, local endpoint, remote port, and application fields are +omitted when the event does not supply them. + +This source is available only through option-aware native managed broker +capture. The guarded-WPR fallback filters ETW by exact workload process +generations, while the normalized network event's ETW header identifies the +broker process, so guarded-WPR analysis intentionally excludes it. ### Verbose logging event signatures From 96bcc01da07a3610aadc911684bb89b4db4577f7 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Wed, 7 Oct 2026 13:22:46 -0700 Subject: [PATCH 09/22] docs: clarify WFP denial output contract Document the source-compatible actionable record, first-observation deduplication, and redacted verbose-only WFP attribution. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e408b60b-e267-416c-806d-0a7e119fe53f --- docs/logging-access-denied.md | 41 +++++++++++++---------------------- 1 file changed, 15 insertions(+), 26 deletions(-) diff --git a/docs/logging-access-denied.md b/docs/logging-access-denied.md index 5283cdd54..5b51f6cb1 100644 --- a/docs/logging-access-denied.md +++ b/docs/logging-access-denied.md @@ -278,15 +278,14 @@ Malformed events, unknown reasons, identity mismatches, and incomplete endpoints are also verbose-only. Reason `65535` remains `unknownNetworkReason`. -Actionable network records include an additive `details` object with -`kind: "network"` and the normalized source, reason, direction, protocol, -endpoint, application ID, and runtime filter ID. The WFP event does not provide -a reliable workload PID, so these records use `pid: 0`; the broker-provided -package, user, and application identities remain available to the decoder for -validation and diagnostics. `filetime` is the original WFP event timestamp -carried in the normalized payload, not the later ETW emission time. +Actionable network records use the existing `DeniedResource` shape. The +normalized protocol, remote address, and optional remote port are encoded in +`resource`; no network-specific field is added to the public Rust type or JSON +record. The WFP event does not provide a reliable workload PID, so these +records use `pid: 0`. `filetime` is the original WFP event timestamp carried +in the normalized payload, not the later ETW emission time. -The current caller-facing network details contract is: +The caller-facing network record is: ```json { @@ -294,27 +293,17 @@ The current caller-facing network details contract is: "resourceType": "network", "accessType": "unknown", "pid": 0, - "filetime": "132847890123512345", - "details": { - "kind": "network", - "source": "processContainerNetworkPolicy", - "reason": "directDefaultDeny", - "direction": "outbound", - "protocol": 6, - "remoteAddress": "203.0.113.10", - "remotePort": 443, - "applicationId": "\\Device\\HarddiskVolume3\\app.exe", - "filterId": "9001" - } + "filetime": "132847890123512345" } ``` -`source` is currently `processContainerNetworkPolicy`; the internal Tessera -component name is not exposed in the public JSON. The actionable reason is -`directDefaultDeny`. Direction is `outbound`, `inbound`, or `unknown`. -`filterId` is a decimal string so JavaScript consumers retain all 64 bits. -Optional protocol, local endpoint, remote port, and application fields are -omitted when the event does not supply them. +The existing `(resource, accessType)` deduplication contract still applies. +When repeated events describe the same endpoint, the actionable record retains +the first observation's `pid` and `filetime`. Per-event source properties such +as direction, filter ID, local endpoint, package identity, and application ID +remain available only in the bounded verbose logging signature. Complete +application paths are redacted there as ``; timestamps are omitted +from the signature so repeated observations can aggregate. This source is available only through option-aware native managed broker capture. The guarded-WPR fallback filters ETW by exact workload process From b6fea6245ec3fe41e715fd6c9b9d9d837701d4c4 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Wed, 7 Oct 2026 13:35:57 -0700 Subject: [PATCH 10/22] docs: version WFP verbose vocabulary Document verbose schema version 3 and require consumers to reject unsupported provider/reason vocabularies. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e408b60b-e267-416c-806d-0a7e119fe53f --- docs/logging-access-denied.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/logging-access-denied.md b/docs/logging-access-denied.md index 5b51f6cb1..80ea9f75b 100644 --- a/docs/logging-access-denied.md +++ b/docs/logging-access-denied.md @@ -349,6 +349,10 @@ policy denial occurrences plus diagnostic outcomes omitted from the policy file: } ``` +Version `3` adds the managed network-decision provider and its closed outcome +reasons. Consumers must reject unsupported document versions rather than +interpreting an unknown provider or reason with an older vocabulary. + Signatures are keyed by symbolic provider category, provider GUID, provider-scoped event ID, schema name, outcome reason from a fixed list, PID, and sorted sanitized properties. SIDs, capability names, GUIDs, PIDs/process identifiers, and From 086eaccad9bce55c9777a11971c297a288e49ba0 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Thu, 8 Oct 2026 10:19:16 -0700 Subject: [PATCH 11/22] docs: explain versioned WFP recommendations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e408b60b-e267-416c-806d-0a7e119fe53f --- docs/development/architecture/telemetry.md | 13 +-- docs/logging-access-denied.md | 98 +++++++++++++++------- 2 files changed, 78 insertions(+), 33 deletions(-) diff --git a/docs/development/architecture/telemetry.md b/docs/development/architecture/telemetry.md index 187e814ef..61552d91f 100644 --- a/docs/development/architecture/telemetry.md +++ b/docs/development/architecture/telemetry.md @@ -200,11 +200,14 @@ Emitted on execution errors. Emitted when a telemetry-enabled ProcessContainer run successfully produces a Learning Mode `captureDenials` verbose logging artifact. MXC reads the -versioned `*.verbose.json` sibling, validates it as a -`VerboseLoggingDocument`, derives each provider GUID from the document's -closed provider enum, drops every verbose property name and value and the -schema name, sums the counts of signatures that become identical, and -serializes that telemetry-specific projection as compact JSON. The event never +versioned `*.verbose.json` sibling and validates document versions 3, 4, and 5 +against their exact closed contracts. It derives each provider GUID from the +version's closed provider vocabulary, drops every verbose property name and +value, schema name, and structured network endpoint, sums the counts of +signatures that become identical after projection, and serializes that +telemetry-specific projection as compact JSON. Version 5 includes the WFP +Learning Mode network-decision provider and its typed reason and configuration +recommendation fields. Unsupported document versions are rejected. The event never contains the actionable denials file, raw ETL, commands, sandbox output, or general logger text. diff --git a/docs/logging-access-denied.md b/docs/logging-access-denied.md index 80ea9f75b..32084b3cc 100644 --- a/docs/logging-access-denied.md +++ b/docs/logging-access-denied.md @@ -251,33 +251,43 @@ MXC currently recognizes two normalized source domains: - App Isolation missing-capability decisions map capability IDs `0`, `1`, and `2` to `internetClient`, `internetClientServer`, and - `privateNetworkClientServer`. + `privateNetworkClientServer`. These decisions remain actionable capability + denials and carry the `addCapability` configuration recommendation in the + version-5 verbose artifact. - Tessera direct-network default-deny decisions map a complete remote endpoint to a `network` resource such as `tcp://203.0.113.10:443` or - `udp://[2001:db8::1]:53`. + `udp://[2001:db8::1]:53`. These decisions remain actionable network denials. `NetworkDecisionV1` retains its original 24-property event ID/version `1` schema. Tessera attribution is carried only in the existing `Reason` field: -| Reason | Meaning | -|---:|---| -| `100` | Direct default deny | -| `101` | Authored explicit deny | -| `102` | Exclusion from an allow rule | -| `103` | Proxy-containment baseline | +| Source/reason | Meaning | Version-5 recommendation | +|---|---|---| +| App Isolation `1` | Missing capability | `addCapability`: add the exact capability named by the actionable record. | +| Tessera `100` | Direct default deny | `addEgressAllow`: author an exact egress allow only when the numeric endpoint and protocol are representable. | +| Tessera `101` | Authored explicit deny | `reviewEgressDeny`: remove or narrow the matching deny if that authored restriction is unintended. Adding an allow does not override a deny. | +| Tessera `102` | Exclusion from an allow rule | `reviewAllowExclusion`: review or narrow the matching `to[].except` entry rather than broadening the existing allow. | +| Tessera `103` | Proxy-containment baseline | `useConfiguredProxy`: route the workload through `runtimeConfig.networkProxy` rather than enabling direct egress. | The internal WFP provider-data format used to produce the reason is not part of the ETW contract. MXC does not parse provider data or require additional policy model, rule-kind, or rule-ordinal fields. Tessera explicit denies, allow exclusions, and proxy-containment decisions are -intentional authored policy rather than missing grants. They are retained in -the verbose logging artifact but are not emitted as policy recommendations; -recommending a direct allow for proxy containment could bypass the proxy. +intentional authored policy rather than missing grants. They do not appear as +actionable `DeniedResource` grant candidates. The version-5 artifact retains +their typed decision reason and the reason-specific review or proxy guidance +shown above. It never recommends a direct allow for these decisions. Malformed events, unknown reasons, identity mismatches, and incomplete -endpoints are also verbose-only. +endpoints are diagnostic-only and receive no success-shaped recommendation. Reason `65535` remains `unknownNetworkReason`. +For reason `100`, the structured endpoint recommendation is emitted only when +MXC can preserve the observed numeric address and a supported protocol without +widening it to `any`. IPv4 addresses map to `/32`, IPv6 addresses map to +`/128`, TCP and UDP retain the observed port when present, and ICMP omits +ports. Unknown protocol encodings do not produce `addEgressAllow`. + Actionable network records use the existing `DeniedResource` shape. The normalized protocol, remote address, and optional remote port are encoded in `resource`; no network-specific field is added to the public Rust type or JSON @@ -310,6 +320,15 @@ capture. The guarded-WPR fallback filters ETW by exact workload process generations, while the normalized network event's ETW header identifies the broker process, so guarded-WPR analysis intentionally excludes it. +The public Rust `DenialAnalyzer` contract remains source-compatible with MXC +1.x. It returns actionable WFP capability and reason-`100` network records +through the existing `DeniedResource` fields, but its existing exhaustive +provider and reason enums cannot truthfully represent the new WFP diagnostic +groups. The compatibility projection therefore does not fabricate a legacy +provider or reason: it omits those groups from the public verbose signature +array and includes their occurrence counts in the existing overflow counters. +MXC's native product capture path retains the complete groups in version 5. + ### Verbose logging event signatures Every successful decode also writes a deterministic sibling file: @@ -319,27 +338,35 @@ policy denial occurrences plus diagnostic outcomes omitted from the policy file: ```json { - "version": 4, + "version": 5, "signatures": [ { "signature": { - "provider": "kernelGeneral", - "providerGuid": "{A68CA8B7-004F-D7B6-A698-07E2DE0F1F5D}", - "eventId": 14, + "provider": "learningModeNetworkDecision", + "providerGuid": "{71237669-21C3-4101-BD2F-FF38945D725A}", + "eventId": 1, + "eventName": "NetworkDecisionV1", "reason": "actionable", - "pid": 4321, - "accessType": "read", - "resourceType": "file", + "pid": 0, + "accessType": "unknown", + "resourceType": "network", + "networkDecisionReason": "directDefaultDeny", + "configurationRecommendation": "addEgressAllow", + "networkEndpoint": { + "protocol": "tcp", + "remoteAddress": "203.0.113.10", + "remotePort": 443 + }, "properties": [ - ["PackageSid", "S-1-15-3-1"], - ["resource", ""] + ["ApplicationId", ""], + ["FilterId", "12345"] ] }, - "count": 37 + "count": 3 } ], "summary": { - "totalOccurrences": 37, + "totalOccurrences": 3, "overflowOccurrences": 0, "actionableOverflowOccurrences": 0, "aggregateGroupsTruncated": false, @@ -349,9 +376,17 @@ policy denial occurrences plus diagnostic outcomes omitted from the policy file: } ``` -Version `3` adds the managed network-decision provider and its closed outcome -reasons. Consumers must reject unsupported document versions rather than -interpreting an unknown provider or reason with an older vocabulary. +Version `3` is the legacy contract preceding scoped verbose diagnostics. +Version `4` adds the schema `eventName` field, the `other` provider category, +and `schemaUnavailable` outcome used by scoped verbose diagnostics. Version +`5` adds MXC's internal WFP provider, WFP-specific outcome reasons, typed +network decision reason, reason-specific configuration recommendation, and +exact endpoint components. Option-aware native +`captureDenials` writes version 5 even when a particular trace contains no WFP +occurrences. Legacy and guarded capture paths may still produce their +corresponding legacy document version. MXC readers that consume captured +artifacts accept versions 3, 4, and 5 and reject every other version rather +than interpreting an unknown provider or reason with an older vocabulary. Signatures are keyed by symbolic provider category, provider GUID, provider-scoped event ID, schema name, outcome reason from a fixed list, PID, and @@ -369,8 +404,15 @@ under the same signature and increment its count. `accessType` and `resourceType` are included when denial extraction determined them; diagnostic outcomes without those classifications omit the fields. -Candidates excluded from the actionable output retain a diagnostic -reason from a fixed list and their sanitized event properties: +Version-5 WFP signatures may also include `networkDecisionReason`, +`configurationRecommendation`, and `networkEndpoint`. The decision reason can +remain present when endpoint decoding is incomplete, but an exact endpoint and +automatic allow recommendation are omitted when the decoder cannot represent +the policy change without broadening it. `FilterId` is diagnostic correlation +only and is never presented as a policy selector. + +Candidates excluded from the actionable output retain a diagnostic reason +from a fixed list and their sanitized event properties: - `notActionable` includes registry writes, registry checks whose access mask cannot be classified as a read, and recognized Section, SymbolicLink, and From 11f3e15ceff5e3d7c88e0fcdaef953a965f9909a Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Thu, 8 Oct 2026 10:36:08 -0700 Subject: [PATCH 12/22] docs: clarify capture network posture Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e408b60b-e267-416c-806d-0a7e119fe53f --- docs/backends/process-container/networking.md | 6 ++++++ docs/logging-access-denied.md | 18 +++++++++++++----- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/docs/backends/process-container/networking.md b/docs/backends/process-container/networking.md index 6bc04a87d..8d70b309f 100644 --- a/docs/backends/process-container/networking.md +++ b/docs/backends/process-container/networking.md @@ -41,6 +41,12 @@ narrows the outbound public and private destinations reachable through the grant | `allow` | `allow` | Both capabilities | Internet outbound and bidirectional private-network traffic are allowed. | This matrix covers the direction defaults without explicit internet egress rules or proxy mode. +It describes the ordinary non-capture capability posture. Native PSEC +`captureDenials` temporarily adds `internetClient` only when the caller +explicitly supplies a direct `network.egress` section, allowing Tessera's WFP +policy to observe and record the attempt while the authored egress table +continues to enforce it. Absent or ingress-only network policy, non-capture +runs, and proxy mode retain the matrix posture. ### Model 1: direct egress, WFP-filtered (least restrictive) diff --git a/docs/logging-access-denied.md b/docs/logging-access-denied.md index 32084b3cc..f62020b49 100644 --- a/docs/logging-access-denied.md +++ b/docs/logging-access-denied.md @@ -49,7 +49,7 @@ surface delivers it explicitly: | Surface | How the warning is delivered | |---------|------------------------------| | Rust | `Sandbox::warnings()` / `Output::warnings` | -| C# | `RunResult.Warnings` | +| C# | `ExecutionResult.Warnings` / `MxcProcess.Warnings` | | C ABI (`mxc_ffi`) | `MxcRunResult::warnings_json_utf8` (JSON array of strings) | | `wxc-exec` | printed to stderr after the run — the CLI owns its terminal | @@ -247,6 +247,14 @@ When option-aware native capture is selected, MXC requests both `ACCESS` and with partial access-only collection. Legacy native capture remains access-only. MXC does not coordinate with WFP directly. +Native PSEC capture has one capture-specific capability exception. When the +caller explicitly supplies a direct `network.egress` section and enables +`captureDenials`, MXC adds `internetClient` so the outbound attempt reaches +Tessera's WFP policy and can be recorded there. The authored egress table still +makes the allow-or-deny decision. This exception is not applied when +`network.egress` is absent, when the request supplies ingress only, when +capture is disabled, or when proxy mode is selected. + MXC currently recognizes two normalized source domains: - App Isolation missing-capability decisions map capability IDs `0`, `1`, and @@ -495,10 +503,10 @@ so CLI callers can locate the deliverable without scanning the filesystem: ``` The pointer echoes the policy file's `summary`; that file is the authoritative -record of denials. In-process Rust callers receive the same summary information through -`Output::output_metadata` or `Sandbox::output_metadata()` after waiting. The -C# SDK exposes it through `RunResult.OutputMetadata` and -`MxcSandboxProcess.OutputMetadata`. +record of denials. In-process Rust callers receive the same summary information +through `Output::output_metadata` or `Sandbox::output_metadata()` after +waiting. The C# SDK exposes it through `ExecutionResult.OutputMetadata` and +`MxcProcess.OutputMetadata`. By default, the intermediate ETW `.etl` trace is an internal, runner-managed file in a protected per-run temporary directory that MXC deletes after From 81ed5c783acc6a3233ede68dd1be73f3304dfa92 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Thu, 8 Oct 2026 10:52:27 -0700 Subject: [PATCH 13/22] docs: document closed verbose contracts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e408b60b-e267-416c-806d-0a7e119fe53f --- docs/development/architecture/telemetry.md | 20 ++++++++-------- docs/logging-access-denied.md | 28 +++++++++++++--------- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/docs/development/architecture/telemetry.md b/docs/development/architecture/telemetry.md index 61552d91f..59b941cb9 100644 --- a/docs/development/architecture/telemetry.md +++ b/docs/development/architecture/telemetry.md @@ -200,16 +200,16 @@ Emitted on execution errors. Emitted when a telemetry-enabled ProcessContainer run successfully produces a Learning Mode `captureDenials` verbose logging artifact. MXC reads the -versioned `*.verbose.json` sibling and validates document versions 3, 4, and 5 -against their exact closed contracts. It derives each provider GUID from the -version's closed provider vocabulary, drops every verbose property name and -value, schema name, and structured network endpoint, sums the counts of -signatures that become identical after projection, and serializes that -telemetry-specific projection as compact JSON. Version 5 includes the WFP -Learning Mode network-decision provider and its typed reason and configuration -recommendation fields. Unsupported document versions are rejected. The event never -contains the actionable denials file, raw ETL, commands, sandbox output, or -general logger text. +versioned `*.verbose.json` sibling and dispatches document versions 3, 4, and 5 +to their separate closed schemas. It rejects cross-version or unknown +vocabulary, derives each provider GUID from the version's closed provider +vocabulary, removes schema `eventName`, every verbose property name and value, +and structured network endpoints, then regroups signatures that become +identical and sums their counts before compact serialization. Version 5 +retains the WFP provider and its closed typed reason and +configuration-recommendation fields. Unsupported document versions are +rejected. The event never contains the actionable denials file, raw ETL, +commands, sandbox output, or general logger text. One verbose document may require multiple ETW events. Every `mxc.content` value is an independently parseable compact JSON array of complete verbose diff --git a/docs/logging-access-denied.md b/docs/logging-access-denied.md index f62020b49..f66c2edb2 100644 --- a/docs/logging-access-denied.md +++ b/docs/logging-access-denied.md @@ -293,8 +293,10 @@ Reason `65535` remains `unknownNetworkReason`. For reason `100`, the structured endpoint recommendation is emitted only when MXC can preserve the observed numeric address and a supported protocol without widening it to `any`. IPv4 addresses map to `/32`, IPv6 addresses map to -`/128`, TCP and UDP retain the observed port when present, and ICMP omits -ports. Unknown protocol encodings do not produce `addEgressAllow`. +`/128`, TCP and UDP require and retain a nonzero observed port, and ICMP +normalizes to a portless rule even if the event carries an inapplicable port +value. Missing or zero TCP/UDP ports and unknown protocol encodings do not +produce `addEgressAllow`. Actionable network records use the existing `DeniedResource` shape. The normalized protocol, remote address, and optional remote port are encoded in @@ -385,16 +387,18 @@ policy denial occurrences plus diagnostic outcomes omitted from the policy file: ``` Version `3` is the legacy contract preceding scoped verbose diagnostics. -Version `4` adds the schema `eventName` field, the `other` provider category, +Version `4` adds the schema `eventName` field, the network-decision provider, and `schemaUnavailable` outcome used by scoped verbose diagnostics. Version -`5` adds MXC's internal WFP provider, WFP-specific outcome reasons, typed -network decision reason, reason-specific configuration recommendation, and -exact endpoint components. Option-aware native +`5` adds MXC's internal WFP-specific outcome reasons, typed network decision +reason, reason-specific configuration recommendation, and exact endpoint +components. Option-aware native `captureDenials` writes version 5 even when a particular trace contains no WFP occurrences. Legacy and guarded capture paths may still produce their corresponding legacy document version. MXC readers that consume captured -artifacts accept versions 3, 4, and 5 and reject every other version rather -than interpreting an unknown provider or reason with an older vocabulary. +artifacts dispatch versions 3, 4, and 5 to separate closed contracts. They +reject unknown fields, newer providers/reasons under an older version label, +and every unsupported version rather than interpreting newer vocabulary with +an older schema. Signatures are keyed by symbolic provider category, provider GUID, provider-scoped event ID, schema name, outcome reason from a fixed list, PID, and @@ -485,9 +489,11 @@ When stable telemetry is enabled and authorized, MXC may validate, compact, and send this redacted verbose document through `Microsoft.MXC/MXC.VerboseDenials`. Each event contains a valid JSON array of complete signatures and document reconstruction metadata. Before emission, MXC derives provider GUIDs from the -predefined provider enum and drops every verbose property name and value. MXC does -not send the actionable denials file, workload-derived properties, or raw ETL -through telemetry. See [MXC telemetry](development/architecture/telemetry.md). +fixed provider enum; removes schema `eventName`, every verbose property name +and value, and structured network endpoints; then regroups signatures that +become identical. MXC does not send the actionable denials file, +workload-derived properties, or raw ETL through telemetry. See +[MXC telemetry](development/architecture/telemetry.md). **Locating the file.** Set `captureDenials.outputPath` to name the file explicitly (its parent directory must already exist). MXC inserts a unique From 1f073871374192debb53a62fef5345c09c8111c5 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Thu, 8 Oct 2026 11:10:59 -0700 Subject: [PATCH 14/22] docs: qualify capture network capability Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e408b60b-e267-416c-806d-0a7e119fe53f --- docs/backends/process-container/networking.md | 9 +++++---- docs/logging-access-denied.md | 16 +++++++++------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/docs/backends/process-container/networking.md b/docs/backends/process-container/networking.md index 8d70b309f..cdf67339c 100644 --- a/docs/backends/process-container/networking.md +++ b/docs/backends/process-container/networking.md @@ -42,10 +42,11 @@ narrows the outbound public and private destinations reachable through the grant This matrix covers the direction defaults without explicit internet egress rules or proxy mode. It describes the ordinary non-capture capability posture. Native PSEC -`captureDenials` temporarily adds `internetClient` only when the caller -explicitly supplies a direct `network.egress` section, allowing Tessera's WFP -policy to observe and record the attempt while the authored egress table -continues to enforce it. Absent or ingress-only network policy, non-capture +`captureDenials` temporarily adds `internetClient` only when the option-aware +trace collects network decisions and the caller explicitly supplies a direct +`network.egress` section, allowing Tessera's WFP policy to observe and record +the attempt while the authored egress table continues to enforce it. Legacy +access-only capture, absent or ingress-only network policy, non-capture runs, and proxy mode retain the matrix posture. ### Model 1: direct egress, WFP-filtered (least restrictive) diff --git a/docs/logging-access-denied.md b/docs/logging-access-denied.md index f66c2edb2..988fede04 100644 --- a/docs/logging-access-denied.md +++ b/docs/logging-access-denied.md @@ -247,13 +247,15 @@ When option-aware native capture is selected, MXC requests both `ACCESS` and with partial access-only collection. Legacy native capture remains access-only. MXC does not coordinate with WFP directly. -Native PSEC capture has one capture-specific capability exception. When the -caller explicitly supplies a direct `network.egress` section and enables -`captureDenials`, MXC adds `internetClient` so the outbound attempt reaches -Tessera's WFP policy and can be recorded there. The authored egress table still -makes the allow-or-deny decision. This exception is not applied when -`network.egress` is absent, when the request supplies ingress only, when -capture is disabled, or when proxy mode is selected. +Option-aware native PSEC capture has one capture-specific capability exception. +When the caller explicitly supplies a direct `network.egress` section and +enables `captureDenials`, MXC adds `internetClient` so the outbound attempt +reaches Tessera's WFP policy and can be recorded there. The authored egress +table still makes the allow-or-deny decision. Legacy access-only native capture +does not add the capability because it cannot collect the resulting network +decision. The exception is also not applied when `network.egress` is absent, +when the request supplies ingress only, when capture is disabled, or when proxy +mode is selected. MXC currently recognizes two normalized source domains: From 9354f7ecc828cf13d97b479b35d3642c01812a88 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Thu, 8 Oct 2026 12:28:22 -0700 Subject: [PATCH 15/22] docs: close WFP review gaps Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e408b60b-e267-416c-806d-0a7e119fe53f --- docs/logging-access-denied.md | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/docs/logging-access-denied.md b/docs/logging-access-denied.md index 988fede04..0dcfc944c 100644 --- a/docs/logging-access-denied.md +++ b/docs/logging-access-denied.md @@ -151,10 +151,10 @@ ungranted access is handled while it is recorded: > with the guarded WPR capture provider. Unsupported hosts return > `backend_unavailable` only when neither path can preserve the full policy. > -> Internal validation confirmed that build `26657.1002` exposes only the -> incompatible earlier contract and is rejected, while build `26663.1000` -> exposes the complete V2 contract. These are validation points, not a public -> Windows release-floor commitment; callers should rely on the runtime probe. +> Internal validation found no native capture support on build `26657.1002` +> and the complete option-aware contract on build `26663.1000`. These are +> validation points, not a public Windows release-floor commitment; callers +> should rely on the runtime probe. > > Native PSEC capture cannot represent `processContainer.leastPrivilege` > because the process security-environment API does not expose an LPAC token @@ -288,9 +288,13 @@ intentional authored policy rather than missing grants. They do not appear as actionable `DeniedResource` grant candidates. The version-5 artifact retains their typed decision reason and the reason-specific review or proxy guidance shown above. It never recommends a direct allow for these decisions. -Malformed events, unknown reasons, identity mismatches, and incomplete -endpoints are diagnostic-only and receive no success-shaped recommendation. -Reason `65535` remains `unknownNetworkReason`. +Malformed events, unknown reasons, identity mismatches, missing or invalid +remote addresses, and mismatched ICMP protocol/address families are +diagnostic-only and receive no success-shaped recommendation. A reason-`100` +event with a valid numeric remote address but an unknown protocol or a missing +or zero TCP/UDP port remains an actionable observed denial, but omits the +structured endpoint and `addEgressAllow` recommendation. Reason `65535` +remains `unknownNetworkReason`. For reason `100`, the structured endpoint recommendation is emitted only when MXC can preserve the observed numeric address and a supported protocol without @@ -322,8 +326,11 @@ The caller-facing network record is: The existing `(resource, accessType)` deduplication contract still applies. When repeated events describe the same endpoint, the actionable record retains the first observation's `pid` and `filetime`. Per-event source properties such -as direction, filter ID, local endpoint, package identity, and application ID -remain available only in the bounded verbose logging signature. Complete +as the `Direction` and `FilterId` fields, local endpoint, package identity, and +application ID remain available only in the bounded verbose logging signature. +`Direction` and `FilterId` are preserved as sanitized numeric strings; +`Direction` has no public enum or caller-facing semantic mapping, and +`FilterId` is diagnostic correlation only, never a policy selector. Complete application paths are redacted there as ``; timestamps are omitted from the signature so repeated observations can aggregate. From 5b46f0e583a48de4564b80d7b1a20dd6d1df15dc Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Fri, 9 Oct 2026 17:17:27 -0700 Subject: [PATCH 16/22] docs: show network denial policy mapping Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e408b60b-e267-416c-806d-0a7e119fe53f --- docs/logging-access-denied.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs/logging-access-denied.md b/docs/logging-access-denied.md index 0dcfc944c..e99aa75b5 100644 --- a/docs/logging-access-denied.md +++ b/docs/logging-access-denied.md @@ -323,6 +323,41 @@ The caller-facing network record is: } ``` +For an actionable reason-`100` record, translate the numeric endpoint without +broadening it. For example, the record above and its `addEgressAllow` +recommendation map to this direct-egress policy: + +```jsonc +{ + "network": { + "egress": { + "default": "deny", + "allow": [ + { + "to": [ + { "cidr": "203.0.113.10/32" } + ], + "ports": [ + { "protocol": "tcp", "port": 443 } + ] + } + ] + }, + "ingress": { + "default": "deny", + "hostLoopback": "deny" + } + } +} +``` + +Use `/128` for an observed IPv6 address. Preserve `udp` and its observed port +the same way. An ICMP endpoint produces a portless rule. Apply this +transformation only when `configurationRecommendation` is `addEgressAllow`; +`reviewEgressDeny`, `reviewAllowExclusion`, and `useConfiguredProxy` require +the reason-specific policy changes in the table above, not an additional allow +rule. + The existing `(resource, accessType)` deduplication contract still applies. When repeated events describe the same endpoint, the actionable record retains the first observation's `pid` and `filetime`. Per-event source properties such From 056505bf1585d6420ac7878e04426a2b41e7f791 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Wed, 14 Oct 2026 10:56:43 -0700 Subject: [PATCH 17/22] docs: correct WFP attribution and native capture versions Document the source-domain and WFP provider/sublayer identity required before interpreting Tessera reasons. Clarify that both option-aware and legacy access-only native capture emit verbose version 5, while guarded WPR emits version 4. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 16300264-31dd-4214-a056-f01395e3179e --- docs/logging-access-denied.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/logging-access-denied.md b/docs/logging-access-denied.md index e99aa75b5..23fdb25be 100644 --- a/docs/logging-access-denied.md +++ b/docs/logging-access-denied.md @@ -269,7 +269,13 @@ MXC currently recognizes two normalized source domains: `udp://[2001:db8::1]:53`. These decisions remain actionable network denials. `NetworkDecisionV1` retains its original 24-property event ID/version `1` -schema. Tessera attribution is carried only in the existing `Reason` field: +schema. Before interpreting Tessera reasons `100`–`103`, MXC requires +`SourceDomain: 2`, `ProviderGuid: {2F8C6D14-3B7E-4A59-9C08-1D4E7A6B2F30}`, +and `SublayerGuid: {7B1E9A2C-9D4F-4C8A-B321-5E6D2F8A1C44}` in the event +payload. These WFP identity fields are distinct from the ETW provider GUID +above; a `Reason` value alone does not establish Tessera attribution. +Once that identity is validated, the existing `Reason` field distinguishes +the policy decisions below: | Source/reason | Meaning | Version-5 recommendation | |---|---|---| @@ -435,10 +441,10 @@ Version `4` adds the schema `eventName` field, the network-decision provider, and `schemaUnavailable` outcome used by scoped verbose diagnostics. Version `5` adds MXC's internal WFP-specific outcome reasons, typed network decision reason, reason-specific configuration recommendation, and exact endpoint -components. Option-aware native -`captureDenials` writes version 5 even when a particular trace contains no WFP -occurrences. Legacy and guarded capture paths may still produce their -corresponding legacy document version. MXC readers that consume captured +components. All native PSEC `captureDenials` results write version 5, +including legacy access-only `StartLearningModeTrace` capture and traces +with no WFP occurrences. The guarded-WPR fallback currently writes version 4 +through the legacy verbose writer. MXC readers that consume captured artifacts dispatch versions 3, 4, and 5 to separate closed contracts. They reject unknown fields, newer providers/reasons under an older version label, and every unsupported version rather than interpreting newer vocabulary with From f04896c1d47d6e3fd8e1e750e5259125689ca54a Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Wed, 14 Oct 2026 11:10:08 -0700 Subject: [PATCH 18/22] docs: complete network reason and schema-failure contracts List every serialized networkDecisionReason value. Clarify network-provider schema failures do not set deniedResourcesTruncated, without contradicting actionable decoded network denials or the guarded-WPR exclusion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 16300264-31dd-4214-a056-f01395e3179e --- docs/logging-access-denied.md | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/docs/logging-access-denied.md b/docs/logging-access-denied.md index 23fdb25be..1a8e5cfe2 100644 --- a/docs/logging-access-denied.md +++ b/docs/logging-access-denied.md @@ -277,13 +277,13 @@ above; a `Reason` value alone does not establish Tessera attribution. Once that identity is validated, the existing `Reason` field distinguishes the policy decisions below: -| Source/reason | Meaning | Version-5 recommendation | -|---|---|---| -| App Isolation `1` | Missing capability | `addCapability`: add the exact capability named by the actionable record. | -| Tessera `100` | Direct default deny | `addEgressAllow`: author an exact egress allow only when the numeric endpoint and protocol are representable. | -| Tessera `101` | Authored explicit deny | `reviewEgressDeny`: remove or narrow the matching deny if that authored restriction is unintended. Adding an allow does not override a deny. | -| Tessera `102` | Exclusion from an allow rule | `reviewAllowExclusion`: review or narrow the matching `to[].except` entry rather than broadening the existing allow. | -| Tessera `103` | Proxy-containment baseline | `useConfiguredProxy`: route the workload through `runtimeConfig.networkProxy` rather than enabling direct egress. | +| Source/reason | Version-5 `networkDecisionReason` | Meaning | Version-5 recommendation | +|---|---|---|---| +| App Isolation `1` | `appIsolationMissingCapability` | Missing capability | `addCapability`: add the exact capability named by the actionable record. | +| Tessera `100` | `directDefaultDeny` | Direct default deny | `addEgressAllow`: author an exact egress allow only when the numeric endpoint and protocol are representable. | +| Tessera `101` | `authoredExplicitDeny` | Authored explicit deny | `reviewEgressDeny`: remove or narrow the matching deny if that authored restriction is unintended. Adding an allow does not override a deny. | +| Tessera `102` | `allowExclusion` | Exclusion from an allow rule | `reviewAllowExclusion`: review or narrow the matching `to[].except` entry rather than broadening the existing allow. | +| Tessera `103` | `proxyContainment` | Proxy-containment baseline | `useConfiguredProxy`: route the workload through `runtimeConfig.networkProxy` rather than enabling direct egress. | The internal WFP provider-data format used to produce the reason is not part of the ETW contract. MXC does not parse provider data or require additional policy @@ -504,9 +504,12 @@ a digest is derived from a sensitive value. Only Learning Mode events are decoded: Kernel-General events 14, 27, and 28, PermissiveLearningMode events 14, 27, and 4907, and NetworkDecision event 1. Other provider and event ID pairs are ignored. `unsupportedEventSchema` means -the event has no actionable extractor. NetworkDecision records are kept only by -unscoped analysis, with PID 0 and that reason; the local file keeps their -sanitized properties, including remote endpoints, while telemetry drops them. +the event has no supported actionable extractor. Native NetworkDecision analysis +emits actionable denials or diagnostic outcomes according to the +[network denial contract](#network-denial-sources) above, with PID 0. Local +verbose logging retains sanitized network properties, including remote +endpoints, while telemetry drops those properties. Guarded-WPR analysis +excludes brokered NetworkDecision records. Per-event TDH failures use these predefined diagnostic reasons: `eventPayloadMalformed` means the payload is malformed or conflicts with its declared schema, @@ -515,11 +518,13 @@ decoding, and `unsupportedPropertyEncoding` means the decoder cannot consume that property shape. When TDH exposes it, the schema-declared name is retained as the bounded `eventName` signature field. Free-form decoder errors are never serialized. `schemaUnavailable` means the event schema could not be -obtained. Analysis continues but sets `deniedResourcesTruncated` because the -unreadable event may have been a denial; network decisions are not denials, so -they do not. Schema failures -remain fatal for raw decoding and for scoping brokered capability events in -guarded traces. +obtained. For non-network Learning Mode events, analysis continues but sets +`deniedResourcesTruncated` because the unreadable event may have been a denial. +NetworkDecision provider schema failures are recorded diagnostically without +setting that flag or producing an actionable record; successfully decoded +network events can still produce the actionable denials described above. +Schema failures remain fatal for raw decoding and for scoping brokered +capability events in guarded traces. To keep diagnostics bounded, verbose logging retains at most 4,096 distinct signatures, 24 sorted properties per signature, and 256 characters per property From 0ed5164599e5ed67caf92e185e7885de3dc16b8c Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Wed, 14 Oct 2026 11:31:46 -0700 Subject: [PATCH 19/22] docs: clarify Tessera is an internal component name Explicitly distinguish the internal component name from the public denial record and verbose JSON provider/reason vocabulary. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 16300264-31dd-4214-a056-f01395e3179e --- docs/logging-access-denied.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/logging-access-denied.md b/docs/logging-access-denied.md index 1a8e5cfe2..97231f312 100644 --- a/docs/logging-access-denied.md +++ b/docs/logging-access-denied.md @@ -257,6 +257,12 @@ decision. The exception is also not applied when `network.egress` is absent, when the request supplies ingress only, when capture is disabled, or when proxy mode is selected. +Tessera is an internal Windows component name used in this guide to explain +direct-network policy enforcement. The name is not exposed in public JSON: +actionable `DeniedResource` records have no `source` field, and verbose +logging uses `provider: "learningModeNetworkDecision"` and the +`networkDecisionReason` values documented below, not `source: "Tessera"`. + MXC currently recognizes two normalized source domains: - App Isolation missing-capability decisions map capability IDs `0`, `1`, and From 7df6c6749fa651924c0278ece99f858b78e5197e Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Wed, 14 Oct 2026 13:18:07 -0700 Subject: [PATCH 20/22] docs: align schema capture proxy and retention guidance Document supported native runtime proxy capture and workload-filtered guarded-WPR retention. Preserve least-privilege and other proxy fallback guidance and replace stale PSEC/V2-only wording. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 16300264-31dd-4214-a056-f01395e3179e --- docs/schema.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/schema.md b/docs/schema.md index 5861fb9c0..aeb1cdb72 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -252,13 +252,16 @@ that can be executed independently. // path in output metadata. Defaults to false. // Retention requires a terminal wait; abandoning the // process handle deletes the internal trace. - // Requires native PSEC/V2 capture; guarded-WPR fallback - // rejects retention rather than exposing a host-wide ETL. + // Supported by native PSEC and guarded-WPR capture. + // Guarded WPR retains only a workload-process-filtered + // ETL, never its host-wide source trace. } // Omit outputPath for a managed JSON output file. - // Native PSEC/V2 capture cannot combine with leastPrivilege - // or runtimeConfig.networkProxy. Hosts without that complete native set - // retain an eligible legacy containment tier and use guarded WPR. + // Native PSEC capture supports runtimeConfig.networkProxy + // without adding the direct-egress capture capability. + // leastPrivilege and other proxy policies use a compatible + // AppContainer tier with guarded WPR. Hosts without compatible + // native capture exports also use that fallback. // If guarded-WPR prerequisites are unavailable, the request // fails before MXC creates the sandbox. }, From cb48248865824405506588ca631f4f9c5c4afdcf Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Wed, 14 Oct 2026 13:29:12 -0700 Subject: [PATCH 21/22] docs: mark verbose artifact example as abridged Clarify that the shortened properties array is illustrative rather than literal producer output or a fixture, and that accepted events retain sanitized schema properties subject to documented omissions and bounds. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 16300264-31dd-4214-a056-f01395e3179e --- docs/logging-access-denied.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/logging-access-denied.md b/docs/logging-access-denied.md index 97231f312..a36b915f9 100644 --- a/docs/logging-access-denied.md +++ b/docs/logging-access-denied.md @@ -400,7 +400,13 @@ MXC's native product capture path retains the complete groups in version 5. Every successful decode also writes a deterministic sibling file: `denials..json` produces `denials..verbose.json`. This verbose logging artifact is a bounded, sensitive-value-redacted superset containing -policy denial occurrences plus diagnostic outcomes omitted from the policy file: +policy denial occurrences plus diagnostic outcomes omitted from the policy file. + +The following is an **abridged, non-literal example**, not a complete producer +output or an expected-output fixture. Only two `properties` entries are shown +for readability. Accepted `NetworkDecisionV1` events contain all 24 schema +properties; the emitted signature retains their sanitized properties subject +to the redaction, timestamp omission, and bounds described below. ```json { From 46273a1f6bf84aced47f413d74586a6a69f5ed4a Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Wed, 14 Oct 2026 14:09:48 -0700 Subject: [PATCH 22/22] docs: distinguish ICMP resource schemes from policy protocol Document portless IPv4 icmp and IPv6 icmpv6 resource representations while preserving the shared icmp protocol in structured endpoints and authored policy. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 16300264-31dd-4214-a056-f01395e3179e --- docs/logging-access-denied.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/logging-access-denied.md b/docs/logging-access-denied.md index a36b915f9..16b2765b8 100644 --- a/docs/logging-access-denied.md +++ b/docs/logging-access-denied.md @@ -364,8 +364,14 @@ recommendation map to this direct-egress policy: ``` Use `/128` for an observed IPv6 address. Preserve `udp` and its observed port -the same way. An ICMP endpoint produces a portless rule. Apply this -transformation only when `configurationRecommendation` is `addEgressAllow`; +the same way. ICMP resources distinguish the address families: +IPv4 uses `icmp://203.0.113.10`, while IPv6 uses +`icmpv6://[2001:db8::1]`. Neither resource includes a port. +For both families, version-5 `networkEndpoint.protocol` and the authored +policy protocol are `"icmp"`; the policy uses `{ "protocol": "icmp" }` +without a `port` field. Do not copy the IPv6 resource scheme `icmpv6` into +the policy protocol. Apply this transformation only when +`configurationRecommendation` is `addEgressAllow`; `reviewEgressDeny`, `reviewAllowExclusion`, and `useConfiguredProxy` require the reason-specific policy changes in the table above, not an additional allow rule.