From ba3db5a93d6f8200e2cfce405fcd9d7242eea63a Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Thu, 27 Aug 2026 21:10:36 -0700 Subject: [PATCH 1/9] Add verbose Learning Mode artifact telemetry Emit sanitized verbose capture artifacts as bounded, independently parseable MXC.Verbose chunks across executor and SDK completion paths. Version the consent disclosure and document the reconstruction and privacy contract. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e408b60b-e267-416c-806d-0a7e119fe53f --- README.md | 6 +- docs/learning-mode/capabilities.md | 6 + docs/telemetry/telemetry-consent-design.md | 5 +- docs/telemetry/telemetry.md | 80 ++++- sdk/dotnet/README.md | 6 + sdk/node/README.md | 7 + src/Cargo.lock | 2 + src/core/mxc_engine/Cargo.toml | 2 + src/core/mxc_engine/src/lib.rs | 100 +++++- src/core/mxc_engine/src/verbose_telemetry.rs | 318 ++++++++++++++++++ src/core/wxc/src/main.rs | 12 + .../resources/telemetry/consent/en-US.json | 4 +- src/core/wxc_common/src/telemetry/consent.rs | 10 +- .../src/telemetry/consent_prompt.rs | 7 +- src/core/wxc_common/src/telemetry/events.rs | 120 ++++++- src/core/wxc_common/src/telemetry/mod.rs | 45 ++- src/mxc_telemetry/src/lib.rs | 108 +++++- .../wxc_e2e_tests/tests/e2e_telemetry_etw.rs | 2 +- .../run_telemetry_consent_smoke_test.ps1 | 2 +- .../scripts/run_telemetry_etw_smoke_test.ps1 | 2 +- 20 files changed, 810 insertions(+), 34 deletions(-) create mode 100644 src/core/mxc_engine/src/verbose_telemetry.rs diff --git a/README.md b/README.md index 566cd3dec6..81f2b8bfd0 100644 --- a/README.md +++ b/README.md @@ -231,7 +231,7 @@ Successful non-dry-run audits require capture metadata, actionable denials JSON, ## Telemetry -MXC supports optional TraceLogging ETW telemetry for execution observability. When enabled, structured events (`MXC.Execution` and `MXC.Error`) are emitted to the local ETW subsystem via the Rust [`tracelogging`](https://crates.io/crates/tracelogging) crate. Every event includes common fields (Version, Channel, IsDebugging, `UTCReplace_AppSessionGuid`) as Part C custom event data. +MXC supports optional TraceLogging ETW telemetry for execution observability. When enabled, structured events (`MXC.Execution`, `MXC.Error`, and the sanitized Learning Mode artifact event `MXC.Verbose`) are emitted to the local ETW subsystem via the Rust [`tracelogging`](https://crates.io/crates/tracelogging) crate. Every event includes common fields (Version, Channel, IsDebugging, `UTCReplace_AppSessionGuid`) as Part C custom event data. Telemetry requires: 1. Top-level `"telemetry": { "enabled": true }` in the JSON config @@ -259,9 +259,9 @@ Windows user consent is granted and administrative policy allows collection. #### What official builds send -Official/shipped Microsoft builds set a TraceLogging provider group GUID at build time and route `MXC.Execution` and `MXC.Error` events to Microsoft through the UTC pipeline when telemetry is enabled — that same build-time setting also selects the correct Measures keyword and Product-and-Service-Usage privacy tag for the events, so telemetry routing and event classification always agree. **Local and open-source builds send nothing to Microsoft by default** — the public source ships without a provider group GUID, so events are emitted to the local ETW subsystem only, use a provider-local keyword with no UTC meaning, and carry no privacy classification tag, and are not routed to any Microsoft collection pipeline. Internal builds that set the `MXC_TELEMETRY_PROVIDER_GROUP_GUID` environment variable at build time enable the Microsoft-routed path. +Official/shipped Microsoft builds set a TraceLogging provider group GUID at build time and route `MXC.Execution`, `MXC.Error`, and `MXC.Verbose` events to Microsoft through the UTC pipeline when telemetry is enabled — that same build-time setting also selects the correct Measures keyword and Product-and-Service-Usage privacy tag for the events, so telemetry routing and event classification always agree. **Local and open-source builds send nothing to Microsoft by default** — the public source ships without a provider group GUID, so events are emitted to the local ETW subsystem only, use a provider-local keyword with no UTC meaning, and carry no privacy classification tag, and are not routed to any Microsoft collection pipeline. Internal builds that set the `MXC_TELEMETRY_PROVIDER_GROUP_GUID` environment variable at build time enable the Microsoft-routed path. -No PII is collected. Events contain only execution metrics (duration, backend type, exit code) and a bounded error category (`error_type`). Free-form error message text is never emitted, so paths, usernames, and credentials cannot leak through telemetry. If you use the SDK to build applications, you are responsible for providing appropriate telemetry notices to your own users. +No PII is collected. Execution/error events contain only execution metrics (duration, backend type, exit code) and a bounded error category (`error_type`). When a ProcessContainer run successfully produces a Learning Mode verbose artifact, `MXC.Verbose` can include its sanitized technical signatures. It excludes commands, credentials, complete file paths, usernames, sandbox output, raw ETL, actionable denial documents, general logger text, and free-form error text. The version 2 consent wording and verbose-event data inventory require explicit privacy/release review before shipment. If you use the SDK to build applications, you are responsible for providing appropriate telemetry notices to your own users. Privacy information can be found at https://privacy.microsoft.com and in the Microsoft privacy statement at https://go.microsoft.com/fwlink/?LinkID=824704. diff --git a/docs/learning-mode/capabilities.md b/docs/learning-mode/capabilities.md index 98a6879f5e..c85f96ebf4 100644 --- a/docs/learning-mode/capabilities.md +++ b/docs/learning-mode/capabilities.md @@ -331,6 +331,12 @@ is intentionally absent from stderr pointers and Rust, Node, C#, and FFI output metadata; callers derive it from the actionable output path using the naming rule above. +When stable telemetry is enabled and authorized, MXC may validate, compact, and +send this redacted verbose document through `Microsoft.MXC/MXC.Verbose`. Each +event contains a valid JSON array of complete signatures and document +reconstruction metadata. MXC does not send the actionable denials file or raw +ETL through telemetry. See [MXC telemetry](../telemetry/telemetry.md). + **Locating the file.** Set `captureDenials.outputPath` to name the file explicitly (its parent directory must already exist). MXC inserts a unique per-run identifier (process id plus random suffix) into the file stem diff --git a/docs/telemetry/telemetry-consent-design.md b/docs/telemetry/telemetry-consent-design.md index 084823e89f..f0cb7b1930 100644 --- a/docs/telemetry/telemetry-consent-design.md +++ b/docs/telemetry/telemetry-consent-design.md @@ -30,7 +30,7 @@ native UI, but may not substitute wording. Current resource: -- Resource version: `1` +- Resource version: `2` - Locale: `en-US` - Mandatory fallback: `en-US` - Privacy link: @@ -45,7 +45,7 @@ Help improve Microsoft eXecution Container (MXC) ```text Help improve MXC by sharing optional diagnostic data with Microsoft. -If enabled, MXC sends diagnostic information about product usage, performance, and reliability. MXC does not send your commands, file paths, credentials, or other customer content. +If enabled, MXC sends diagnostic information about product usage, performance, and reliability. When Learning Mode capture is used, this can include sanitized technical details about resource-access events, such as provider and event identifiers, process IDs, access classifications, and bounded redacted properties. MXC does not send your commands, credentials, complete file paths, usernames, or sandbox output. You can change your choice at any time. ``` @@ -195,6 +195,7 @@ The public ETW event identities remain: - `MXC.Execution` - `MXC.Error` +- `MXC.Verbose` Collectors may continue filtering those names; consent stabilization does not rename the provider or events. diff --git a/docs/telemetry/telemetry.md b/docs/telemetry/telemetry.md index 7db7618ff4..03dfd6a2a3 100644 --- a/docs/telemetry/telemetry.md +++ b/docs/telemetry/telemetry.md @@ -18,7 +18,8 @@ C++ shim, WIL, or FFI is required. │ wxc_common::telemetry │ │ (Rust — config resolution, sanitisation, types) │ │ │ -│ init() / log_execution() / log_error() / shutdown() │ +│ init() / log_execution() / log_error() / │ +│ log_verbose() / shutdown() │ └───────────────┬──────────────────────────────────────┘ │ Direct Rust function calls ▼ @@ -113,9 +114,12 @@ targets for this work. ### ETW privacy and collection contract -- Events contain bounded enums, opaque MXC identities, numeric status fields, - and schema field paths only. They do not contain command lines, environment - values, raw paths, UPNs, tokens, or free-form error text. +- Execution, error, and M-ETW requirement events contain bounded enums, opaque + MXC identities, numeric status fields, and schema field paths only. + `MXC.Verbose` is separately limited to the sanitized, typed inventory below. +- Events do not contain command lines, environment values, complete file paths, + UPNs, tokens, sandbox output, raw ETL, actionable denial documents, general + logger text, or free-form error text. - `MXC.PolicyHash` uses the same effective container identity as the runner (`CLI` when no container ID was supplied), then applies the standard identity redaction. Valid opaque IDs are preserved; UPN-shaped or otherwise unsafe @@ -141,6 +145,11 @@ targets for this work. ## Events +The provider-qualified event identities include +`Microsoft.MXC/MXC.Execution`, `Microsoft.MXC/MXC.Error`, and +`Microsoft.MXC/MXC.Verbose`. `Microsoft.MXC` is the TraceLogging provider +name. The M-ETW requirement events listed above share that provider. + ### MXC.Execution Emitted when a one-shot execution completes (success or failure). It is also @@ -184,6 +193,69 @@ Emitted on execution errors. > attribution fields, the `error_type` category, and the numeric `exit_code` — > never the message string itself. +### MXC.Verbose + +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`, and reserializes it as compact JSON. 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 +signature objects; MXC never splits a JSON object or UTF-8 code point. + +| Field | Type | Description | +|-------|------|-------------| +| `mxc.sandbox_kind` | string | Caller-requested containment kind | +| `mxc.backend` | string | Concrete containment backend | +| `mxc.phase` | string | Lifecycle phase; empty for current one-shot capture | +| `__TlgCV__` | string | Lifecycle correlation vector; empty for current one-shot capture | +| `mxc.document_id` | string | Random identifier shared only by chunks of one verbose artifact | +| `mxc.document_version` | uint32 | `VerboseLoggingDocument` schema version | +| `mxc.chunk_index` | uint32 | Zero-based chunk index | +| `mxc.chunk_count` | uint32 | Total number of chunks | +| `mxc.document_bytes` | uint64 | Canonical compact document byte count | +| `mxc.document_sha256` | string | SHA-256 of the canonical compact document | +| `mxc.content` | string | Valid compact JSON array of complete verbose signatures | +| `mxc.summary` | string | Valid compact JSON verbose-document summary | + +Consumers group by the existing app-session identifier plus +`mxc.document_id`, order by `mxc.chunk_index`, parse and concatenate the +`mxc.content` arrays, then rebuild `{version, signatures, summary}`. The byte +count and SHA-256 allow consumers to reject incomplete or corrupted +reconstructions. + +### Data inventory + +MXC's optional diagnostic events contain: + +- MXC version and channel +- Whether the build has debug assertions enabled (`IsDebugging`) +- Caller-requested sandbox kind and the concrete backend selected on the host +- Run outcome, exit code, duration, bounded failure category, and lifecycle + phase +- `UTCReplace_AppSessionGuid`, which asks the telemetry pipeline to supply a + random per-session app identifier +- An MXC-internal random lifecycle correlation vector +- Sanitized Learning Mode verbose signatures when `captureDenials` produces a + verbose artifact: provider/event identifiers, PID, closed outcome reason, + access/resource classifications, bounded redacted properties, occurrence + counts, and truncation state + +MXC does not emit commands, credentials, complete file paths, usernames, +sandbox output, raw ETL, actionable denial documents, general logger text, or +free-form error text. + +### Privacy review status + +The version 2 consent wording, verbose-event inventory, current WinExt +classification, retention, access, regional processing, deletion, and +localization/accessibility decisions require explicit privacy/release review. +The canonical wording and presenter contract are documented in +[Telemetry consent design](telemetry-consent-design.md). + ### Correlating a lifecycle The state-aware lifecycle runs each phase (`provision` → `start` → `exec` → diff --git a/sdk/dotnet/README.md b/sdk/dotnet/README.md index 7849735f61..0d78e37ace 100644 --- a/sdk/dotnet/README.md +++ b/sdk/dotnet/README.md @@ -486,6 +486,12 @@ MXC telemetry is Windows-only and remains off until both of these are true: Telemetry remains off by default unless the caller opts in with `SandboxPolicy.TelemetryEnabled = true` (or the equivalent phase-level `TelemetryEnabled` setting for state-aware requests) and applicable Windows consent/policy gates permit collection. +When a telemetry-enabled Windows ProcessContainer run successfully produces a +Learning Mode `captureDenials` verbose artifact, telemetry can include its +sanitized technical signatures. It does not include commands, credentials, +complete file paths, usernames, sandbox output, raw ETL, or general logger +text. See the [telemetry data inventory](../../docs/telemetry/telemetry.md#data-inventory). + Any .NET consent surface should stay UI-agnostic, present the canonical resource verbatim through a host callback, persist only explicit yes/no decisions, treat dismissal and failures as non-grants, and follow the rules in diff --git a/sdk/node/README.md b/sdk/node/README.md index d01f9cae90..257bf4bd39 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -498,6 +498,13 @@ Full TypeScript definitions ship with the package (`dist/index.d.ts`). All expor Telemetry is off-by-default unless the caller opts in with top-level `telemetry.enabled: true` and the applicable Windows consent/policy gates permit collection. +When a telemetry-enabled Windows ProcessContainer run successfully produces a +Learning Mode `captureDenials` verbose artifact, telemetry can include its +sanitized technical signatures. It does not include commands, credentials, +complete file paths, usernames, sandbox output, raw ETL, or general logger +text. See the +[telemetry data inventory](https://github.com/microsoft/mxc/blob/main/docs/telemetry/telemetry.md#data-inventory). + Telemetry consent behavior follows [`docs/telemetry/telemetry-consent-design.md`](https://github.com/microsoft/mxc/blob/main/docs/telemetry/telemetry-consent-design.md): the SDK stays UI-agnostic, renders the canonical resource verbatim through a diff --git a/src/Cargo.lock b/src/Cargo.lock index 104f073762..018922434d 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -1542,6 +1542,7 @@ version = "0.8.0" dependencies = [ "appcontainer_common", "bwrap_common", + "getrandom 0.2.17", "hyperlight_common", "isolation_session_bindings", "isolation_session_common", @@ -1552,6 +1553,7 @@ dependencies = [ "seatbelt_common", "serde", "serde_json", + "sha2 0.10.9", "tempfile", "windows", "windows_sandbox_lifecycle", diff --git a/src/core/mxc_engine/Cargo.toml b/src/core/mxc_engine/Cargo.toml index 3b76341af1..7813e523a2 100644 --- a/src/core/mxc_engine/Cargo.toml +++ b/src/core/mxc_engine/Cargo.toml @@ -34,6 +34,8 @@ plm = { workspace = true } # ProcessLifetime / AnalysisResult types shared across the guarded-capture DI # boundary; appcontainer_common and plm already depend on this crate. learning_mode_core = { workspace = true } +getrandom.workspace = true +sha2.workspace = true windows.workspace = true [target.'cfg(target_os = "linux")'.dependencies] diff --git a/src/core/mxc_engine/src/lib.rs b/src/core/mxc_engine/src/lib.rs index b7d09acb3d..1bb81f5e36 100644 --- a/src/core/mxc_engine/src/lib.rs +++ b/src/core/mxc_engine/src/lib.rs @@ -40,6 +40,8 @@ mod probe; #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] mod run; mod state_aware; +#[cfg(target_os = "windows")] +mod verbose_telemetry; pub use error::{Error, ErrorCode}; #[cfg(all(target_os = "windows", feature = "isolation_session"))] @@ -59,6 +61,8 @@ pub use run::{log_policy_hash, resolve_runner, run, ResolvedRunner}; pub use state_aware::{ exec_state_aware_attached, exec_state_aware_json, run_state_aware, run_state_aware_json, }; +#[cfg(target_os = "windows")] +pub use verbose_telemetry::emit_verbose_telemetry; use wxc_common::logger::{Logger, Mode}; use wxc_common::models::{ContainmentBackend, FailurePhase, ScriptResponse}; @@ -241,9 +245,15 @@ impl TelemetryProcess { if !self.active { return; } + let output_metadata = result + .as_ref() + .ok() + .and_then(|_| self.inner.output_metadata().cloned()) + .map(Box::new); let response = match result { Ok(exit_code) => ScriptResponse { exit_code: *exit_code, + output_metadata, ..Default::default() }, Err(error) if error.kind() == std::io::ErrorKind::TimedOut => ScriptResponse { @@ -261,13 +271,18 @@ impl TelemetryProcess { TelemetryMode::OneShot { containment, requested_sandbox_kind, - } => telemetry::emit_sdk_completion_with_kind( - true, - containment, - *requested_sandbox_kind, - &response, - self.started.elapsed(), - ), + } => { + #[cfg(target_os = "windows")] + let _ = + emit_verbose_telemetry(true, containment, *requested_sandbox_kind, &response); + telemetry::emit_sdk_completion_with_kind( + true, + containment, + *requested_sandbox_kind, + &response, + self.started.elapsed(), + ) + } TelemetryMode::StateAware { backend, phase, @@ -405,7 +420,12 @@ impl Drop for TelemetryProcess { return; } match observe_before_drop(self.inner.as_mut()) { - DropDisposition::Exited(exit_code) => self.emit(&Ok(exit_code)), + DropDisposition::Exited(exit_code) => { + // Finalize backend-owned output metadata after the nonblocking + // observation, while preserving the already-observed exit code. + let _ = self.inner.wait(); + self.emit(&Ok(exit_code)); + } DropDisposition::TimedOut => self.emit(&Err(std::io::Error::new( std::io::ErrorKind::TimedOut, "sandbox execution timed out", @@ -439,8 +459,13 @@ impl SandboxProcess for TelemetryProcess { fn try_wait(&mut self) -> std::io::Result> { let result = self.inner.try_wait(); match &result { - // Exit observed: emit the terminal event now. - Ok(Some(exit_code)) => self.emit(&Ok(*exit_code)), + // Finalize backend-owned output metadata after observing exit, + // while preserving the nonblocking poll's terminal result. + Ok(Some(exit_code)) => { + let exit_code = *exit_code; + let _ = self.inner.wait(); + self.emit(&Ok(exit_code)); + } // Still running: leave the invariant to a later `wait` / `kill` / `Drop`. Ok(None) => {} // Backends use TimedOut only for a settled terminal timeout. @@ -573,6 +598,8 @@ mod telemetry_process_tests { try_wait_result: TryWaitResult, wait_result: std::io::Result, kill_fails: bool, + finalized: Option>, + metadata_read_before_finalization: Option>, } impl SandboxProcess for StubProcess { @@ -613,11 +640,25 @@ mod telemetry_process_tests { } fn wait(&mut self) -> std::io::Result { + if let Some(finalized) = &self.finalized { + finalized.store(true, std::sync::atomic::Ordering::SeqCst); + } self.wait_result .as_ref() .copied() .map_err(|error| std::io::Error::new(error.kind(), error.to_string())) } + + fn output_metadata(&self) -> Option<&wxc_common::models::SandboxOutputMetadata> { + if let (Some(finalized), Some(read_before_finalization)) = + (&self.finalized, &self.metadata_read_before_finalization) + { + if !finalized.load(std::sync::atomic::Ordering::SeqCst) { + read_before_finalization.store(true, std::sync::atomic::Ordering::SeqCst); + } + } + None + } } fn wrapped(try_wait_result: TryWaitResult) -> TelemetryProcess { @@ -633,6 +674,8 @@ mod telemetry_process_tests { try_wait_result, wait_result: Ok(0), kill_fails, + finalized: None, + metadata_read_before_finalization: None, }), active: true, mode: TelemetryMode::StateAware { @@ -710,6 +753,8 @@ mod telemetry_process_tests { try_wait_result: TryWaitResult::Exited(7), wait_result: Ok(0), kill_fails: false, + finalized: None, + metadata_read_before_finalization: None, }; assert_eq!(observe_before_drop(&mut exited), DropDisposition::Exited(7)); @@ -717,6 +762,8 @@ mod telemetry_process_tests { try_wait_result: TryWaitResult::Running, wait_result: Ok(0), kill_fails: false, + finalized: None, + metadata_read_before_finalization: None, }; assert_eq!( observe_before_drop(&mut running), @@ -727,6 +774,8 @@ mod telemetry_process_tests { try_wait_result: TryWaitResult::Failed, wait_result: Ok(0), kill_fails: false, + finalized: None, + metadata_read_before_finalization: None, }; assert_eq!(observe_before_drop(&mut failed), DropDisposition::Abandoned); @@ -734,6 +783,8 @@ mod telemetry_process_tests { try_wait_result: TryWaitResult::TimedOut, wait_result: Ok(0), kill_fails: false, + finalized: None, + metadata_read_before_finalization: None, }; assert_eq!( observe_before_drop(&mut timed_out), @@ -750,4 +801,33 @@ mod telemetry_process_tests { assert_eq!(process.wait().unwrap(), 0); assert!(!process.active); } + + #[test] + fn terminal_poll_finalizes_before_reading_output_metadata() { + let finalized = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let metadata_read_before_finalization = + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let mut process = TelemetryProcess { + inner: Box::new(StubProcess { + try_wait_result: TryWaitResult::Exited(7), + wait_result: Ok(7), + kill_fails: false, + finalized: Some(finalized.clone()), + metadata_read_before_finalization: Some(metadata_read_before_finalization.clone()), + }), + active: true, + mode: TelemetryMode::StateAware { + backend: "test".to_string(), + phase: "exec".to_string(), + correlation_vector: String::new(), + requested_sandbox_kind: None, + }, + started: std::time::Instant::now(), + }; + + assert_eq!(process.try_wait().unwrap(), Some(7)); + assert!(finalized.load(std::sync::atomic::Ordering::SeqCst)); + assert!(!metadata_read_before_finalization.load(std::sync::atomic::Ordering::SeqCst)); + assert!(!process.active); + } } diff --git a/src/core/mxc_engine/src/verbose_telemetry.rs b/src/core/mxc_engine/src/verbose_telemetry.rs new file mode 100644 index 0000000000..5893071d5a --- /dev/null +++ b/src/core/mxc_engine/src/verbose_telemetry.rs @@ -0,0 +1,318 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Telemetry transport for the canonical Learning Mode verbose JSON artifact. + +use std::io::Read; +use std::path::Path; + +use learning_mode_core::{verbose_logging_sibling_path, VerboseLoggingDocument}; +use sha2::{Digest, Sha256}; +use wxc_common::models::{ContainmentBackend, ScriptResponse}; +use wxc_common::telemetry::{self, VerboseEvent}; + +/// Leaves room for TraceLogging metadata beneath ETW's approximately 64 KiB +/// event limit. +const MAX_CONTENT_BYTES: usize = 48 * 1024; +/// The pretty-printed file is bounded separately from its compact form. +const MAX_INPUT_BYTES: u64 = 32 * 1024 * 1024; + +#[derive(Debug)] +struct PreparedVerboseDocument { + document_id: String, + version: u32, + document_bytes: u64, + document_sha256: String, + summary: String, + chunks: Vec, +} + +/// Emit the verbose artifact referenced by a successful capture-denials result. +/// +/// `Ok(())` also covers responses with no capture artifact. Telemetry failures +/// never alter the sandbox result; callers may surface the bounded error only +/// through local diagnostics. +pub fn emit_verbose_telemetry( + active: bool, + containment: &ContainmentBackend, + requested_sandbox_kind: Option<&'static str>, + response: &ScriptResponse, +) -> Result<(), String> { + if !active { + return Ok(()); + } + let Some(prepared) = prepare_from_response(response)? else { + return Ok(()); + }; + + let backend = containment.wire_name(); + let sandbox_kind = requested_sandbox_kind.unwrap_or(backend); + let chunk_count = u32::try_from(prepared.chunks.len()) + .map_err(|_| "verbose artifact produced too many telemetry chunks".to_string())?; + for (index, content) in prepared.chunks.iter().enumerate() { + let chunk_index = u32::try_from(index) + .map_err(|_| "verbose artifact chunk index exceeded u32".to_string())?; + if !telemetry::emit_verbose( + true, + &VerboseEvent { + backend, + sandbox_kind, + phase: "", + correlation_vector: "", + document_id: &prepared.document_id, + document_version: prepared.version, + chunk_index, + chunk_count, + document_bytes: prepared.document_bytes, + document_sha256: &prepared.document_sha256, + content, + summary: &prepared.summary, + }, + ) { + break; + } + } + Ok(()) +} + +fn prepare_from_response( + response: &ScriptResponse, +) -> Result, String> { + let Some(capture) = response + .output_metadata + .as_deref() + .and_then(|metadata| metadata.capture_denials.as_ref()) + else { + return Ok(None); + }; + let path = verbose_logging_sibling_path(Path::new(&capture.output_path)) + .map_err(|_| "could not derive the verbose artifact path".to_string())?; + prepare_document(&path).map(Some) +} + +fn prepare_document(path: &Path) -> Result { + let file = + std::fs::File::open(path).map_err(|_| "could not open the verbose artifact".to_string())?; + let metadata = file + .metadata() + .map_err(|_| "could not inspect the verbose artifact".to_string())?; + if metadata.len() > MAX_INPUT_BYTES { + return Err("verbose artifact exceeds the telemetry input bound".to_string()); + } + + let capacity = usize::try_from(metadata.len()) + .map_err(|_| "verbose artifact size exceeds this platform".to_string())?; + let mut bytes = Vec::with_capacity(capacity); + file.take(MAX_INPUT_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|_| "could not read the verbose artifact".to_string())?; + if bytes.len() as u64 > MAX_INPUT_BYTES { + return Err("verbose artifact exceeds the telemetry input bound".to_string()); + } + + let document: VerboseLoggingDocument = serde_json::from_slice(&bytes) + .map_err(|_| "verbose artifact is not valid typed JSON".to_string())?; + if document.version != VerboseLoggingDocument::VERSION { + return Err("verbose artifact uses an unsupported document version".to_string()); + } + + let compact = serde_json::to_vec(&document) + .map_err(|_| "could not compact the verbose artifact".to_string())?; + let document_bytes = u64::try_from(compact.len()) + .map_err(|_| "verbose artifact byte count exceeded u64".to_string())?; + let document_sha256 = format!("{:x}", Sha256::digest(&compact)); + let summary = serde_json::to_string(&document.summary) + .map_err(|_| "could not serialize the verbose artifact summary".to_string())?; + let chunks = chunk_signatures(&document)?; + let document_id = random_document_id()?; + + Ok(PreparedVerboseDocument { + document_id, + version: document.version, + document_bytes, + document_sha256, + summary, + chunks, + }) +} + +fn chunk_signatures(document: &VerboseLoggingDocument) -> Result, String> { + let mut chunks = Vec::new(); + let mut current = String::from("["); + let mut current_count = 0usize; + + for signature in &document.signatures { + let item = serde_json::to_string(signature) + .map_err(|_| "could not serialize a verbose signature".to_string())?; + let separator_bytes = usize::from(current_count > 0); + let candidate_bytes = current + .len() + .saturating_add(separator_bytes) + .saturating_add(item.len()) + .saturating_add(1); + + if candidate_bytes > MAX_CONTENT_BYTES && current_count > 0 { + current.push(']'); + chunks.push(current); + current = String::from("["); + current_count = 0; + } + + if item.len().saturating_add(2) > MAX_CONTENT_BYTES { + return Err("one verbose signature exceeds the telemetry event bound".to_string()); + } + if current_count > 0 { + current.push(','); + } + current.push_str(&item); + current_count += 1; + } + + current.push(']'); + chunks.push(current); + Ok(chunks) +} + +fn random_document_id() -> Result { + let mut bytes = [0u8; 16]; + getrandom::getrandom(&mut bytes) + .map_err(|_| "could not generate a verbose telemetry document id".to_string())?; + Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + use learning_mode_core::{ + VerboseLoggingAggregate, VerboseLoggingDocumentSummary, VerboseLoggingOutcomeReason, + VerboseLoggingProvider, VerboseLoggingSignature, + }; + use wxc_common::models::{CaptureDenialsOutput, SandboxOutputMetadata}; + + fn aggregate(event_id: u16, value: &str) -> VerboseLoggingAggregate { + VerboseLoggingAggregate { + signature: VerboseLoggingSignature { + provider: VerboseLoggingProvider::KernelGeneral, + provider_guid: "{a68ca8b7-004f-d7b6-a698-07e2de0f1f5d}".to_string(), + event_id, + reason: VerboseLoggingOutcomeReason::UnsupportedEventSchema, + pid: 42, + access_type: None, + resource_type: None, + properties: vec![("Value".to_string(), value.to_string())], + }, + count: 1, + } + } + + fn document(signatures: Vec) -> VerboseLoggingDocument { + VerboseLoggingDocument { + version: VerboseLoggingDocument::VERSION, + signatures, + summary: VerboseLoggingDocumentSummary { + total_occurrences: 1, + overflow_occurrences: 0, + actionable_overflow_occurrences: 0, + aggregate_groups_truncated: false, + processed_events_truncated: false, + actionable_limit_reached: false, + }, + } + } + + #[test] + fn every_chunk_is_an_independently_parseable_json_array() { + let doc = document(vec![aggregate(1, "one"), aggregate(2, "two")]); + let chunks = chunk_signatures(&doc).unwrap(); + let rebuilt = chunks + .iter() + .flat_map(|chunk| serde_json::from_str::>(chunk).unwrap()) + .collect::>(); + assert_eq!(rebuilt, doc.signatures); + } + + #[test] + fn empty_document_emits_one_empty_array() { + assert_eq!(chunk_signatures(&document(Vec::new())).unwrap(), ["[]"]); + } + + #[test] + fn chunks_only_between_complete_aggregates() { + let large = "x".repeat(MAX_CONTENT_BYTES / 2); + let doc = document(vec![aggregate(1, &large), aggregate(2, &large)]); + let chunks = chunk_signatures(&doc).unwrap(); + assert_eq!(chunks.len(), 2); + for chunk in chunks { + let parsed: Vec = serde_json::from_str(&chunk).unwrap(); + assert_eq!(parsed.len(), 1); + } + } + + #[test] + fn rejects_unsupported_document_version() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("denials.verbose.json"); + let mut doc = document(Vec::new()); + doc.version += 1; + std::fs::write(&path, serde_json::to_vec(&doc).unwrap()).unwrap(); + assert!(prepare_document(&path) + .unwrap_err() + .contains("unsupported document version")); + } + + #[test] + fn prepared_chunks_reconstruct_the_canonical_document() { + let directory = tempfile::tempdir().unwrap(); + let denials_path = directory.path().join("denials.json"); + let verbose_path = verbose_logging_sibling_path(&denials_path).unwrap(); + let doc = document(vec![aggregate(1, "one"), aggregate(2, "two")]); + let mut pretty = Vec::new(); + learning_mode_core::write_verbose_logging_document(&mut pretty, &doc).unwrap(); + std::fs::write(&verbose_path, pretty).unwrap(); + + let response = ScriptResponse { + output_metadata: Some(Box::new(SandboxOutputMetadata { + capture_denials: Some(CaptureDenialsOutput { + kind: CaptureDenialsOutput::KIND.to_string(), + output_path: denials_path.to_string_lossy().into_owned(), + exit_code: 0, + total_denials: 0, + denied_resources_truncated: false, + etl_path: None, + }), + capture_denials_error: None, + })), + ..Default::default() + }; + let prepared = prepare_from_response(&response).unwrap().unwrap(); + let signatures = prepared + .chunks + .iter() + .flat_map(|chunk| serde_json::from_str::>(chunk).unwrap()) + .collect(); + let summary = serde_json::from_str(&prepared.summary).unwrap(); + let reconstructed = VerboseLoggingDocument { + version: prepared.version, + signatures, + summary, + }; + let compact = serde_json::to_vec(&reconstructed).unwrap(); + + assert_eq!(compact.len() as u64, prepared.document_bytes); + assert_eq!( + format!("{:x}", Sha256::digest(&compact)), + prepared.document_sha256 + ); + assert_eq!(reconstructed, doc); + } + + #[test] + fn malformed_artifact_never_falls_back_to_raw_bytes() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("denials.verbose.json"); + std::fs::write(&path, br#"{"secret":"do-not-send"}"#).unwrap(); + let error = prepare_document(&path).unwrap_err(); + assert_eq!(error, "verbose artifact is not valid typed JSON"); + assert!(!error.contains("do-not-send")); + } +} diff --git a/src/core/wxc/src/main.rs b/src/core/wxc/src/main.rs index c5da7d58e5..572f38ea2f 100644 --- a/src/core/wxc/src/main.rs +++ b/src/core/wxc/src/main.rs @@ -1684,6 +1684,18 @@ fn main() { eprintln!("{warning}"); } + #[cfg(target_os = "windows")] + if let Err(error) = mxc_engine::emit_verbose_telemetry( + telemetry_active, + &request.containment, + requested_sandbox_kind, + &response, + ) { + logger.log_diagnostic_line(&format!( + "telemetry: captureDenials verbose artifact was not emitted: {error}" + )); + } + telemetry::emit_completion_with_kind( telemetry_active, &request.containment, diff --git a/src/core/wxc_common/resources/telemetry/consent/en-US.json b/src/core/wxc_common/resources/telemetry/consent/en-US.json index 228efa0c72..f2448c1d48 100644 --- a/src/core/wxc_common/resources/telemetry/consent/en-US.json +++ b/src/core/wxc_common/resources/telemetry/consent/en-US.json @@ -1,5 +1,5 @@ { - "resourceVersion": 1, + "resourceVersion": 2, "locale": "en-US", "messages": { "title": { @@ -8,7 +8,7 @@ }, "body": { "id": "telemetry-consent-body", - "text": "Help improve MXC by sharing optional diagnostic data with Microsoft.\nIf enabled, MXC sends diagnostic information about product usage, performance, and reliability. MXC does not send your commands, file paths, credentials, or other customer content.\nYou can change your choice at any time." + "text": "Help improve MXC by sharing optional diagnostic data with Microsoft.\nIf enabled, MXC sends diagnostic information about product usage, performance, and reliability. When Learning Mode capture is used, this can include sanitized technical details about resource-access events, such as provider and event identifiers, process IDs, access classifications, and bounded redacted properties. MXC does not send your commands, credentials, complete file paths, usernames, or sandbox output.\nYou can change your choice at any time." }, "affirmativeLabel": { "id": "telemetry-consent-affirmative-label", diff --git a/src/core/wxc_common/src/telemetry/consent.rs b/src/core/wxc_common/src/telemetry/consent.rs index 4e15ec465e..f50c70741c 100644 --- a/src/core/wxc_common/src/telemetry/consent.rs +++ b/src/core/wxc_common/src/telemetry/consent.rs @@ -1784,7 +1784,10 @@ mod tests { env.set_policy_value(3); let outcome = request_consent(Some("en-US"), |prompt| { - assert_eq!(prompt.resource_version, 1); + assert_eq!( + prompt.resource_version, + crate::telemetry::consent_prompt::CONSENT_RESOURCE_VERSION + ); assert_eq!(prompt.locale, "en-US"); Ok(ConsentDecision::Yes) }) @@ -1803,7 +1806,10 @@ mod tests { env.set_policy_value(3); let outcome = block_on(request_consent_async(Some("en-US"), |prompt| { - assert_eq!(prompt.resource_version, 1); + assert_eq!( + prompt.resource_version, + crate::telemetry::consent_prompt::CONSENT_RESOURCE_VERSION + ); assert_eq!(prompt.locale, "en-US"); async { Ok(ConsentDecision::Yes) } })) diff --git a/src/core/wxc_common/src/telemetry/consent_prompt.rs b/src/core/wxc_common/src/telemetry/consent_prompt.rs index 84597e5a03..9839d90dd1 100644 --- a/src/core/wxc_common/src/telemetry/consent_prompt.rs +++ b/src/core/wxc_common/src/telemetry/consent_prompt.rs @@ -74,8 +74,11 @@ mod tests { prompt.body.text, "Help improve MXC by sharing optional diagnostic data with Microsoft.\n\ If enabled, MXC sends diagnostic information about product usage, performance, \ - and reliability. MXC does not send your commands, file paths, credentials, or \ - other customer content.\n\ + and reliability. When Learning Mode capture is used, this can include sanitized \ + technical details about resource-access events, such as provider and event \ + identifiers, process IDs, access classifications, and bounded redacted properties. \ + MXC does not send your commands, credentials, complete file paths, usernames, or \ + sandbox output.\n\ You can change your choice at any time." ); assert_eq!(prompt.affirmative_label.text, "Yes"); diff --git a/src/core/wxc_common/src/telemetry/events.rs b/src/core/wxc_common/src/telemetry/events.rs index 966a3ca1d7..c8e4e8b414 100644 --- a/src/core/wxc_common/src/telemetry/events.rs +++ b/src/core/wxc_common/src/telemetry/events.rs @@ -84,6 +84,25 @@ pub struct ExecutionEvent<'a> { pub correlation_vector: &'a str, } +/// One independently parseable chunk of a Learning Mode verbose document. +#[derive(Debug, Clone, Copy)] +pub struct VerboseEvent<'a> { + pub backend: &'a str, + pub sandbox_kind: &'a str, + pub phase: &'a str, + pub correlation_vector: &'a str, + pub document_id: &'a str, + pub document_version: u32, + pub chunk_index: u32, + pub chunk_count: u32, + pub document_bytes: u64, + pub document_sha256: &'a str, + /// Compact JSON array of complete `VerboseLoggingAggregate` objects. + pub content: &'a str, + /// Compact JSON `VerboseLoggingDocumentSummary` object. + pub summary: &'a str, +} + /// Log an Execution ETW event. /// /// Delegates to the `mxc_telemetry` provider which adds common fields @@ -133,6 +152,27 @@ pub fn log_error( test_sink::record_error(ctx, sandbox_kind, error_type, exit_code); } +/// Log one `MXC.Verbose` ETW event. +pub fn log_verbose(event: &VerboseEvent<'_>) { + mxc_telemetry::log_verbose( + event.backend, + event.sandbox_kind, + event.phase, + event.correlation_vector, + event.document_id, + event.document_version, + event.chunk_index, + event.chunk_count, + event.document_bytes, + event.document_sha256, + event.content, + event.summary, + ); + + #[cfg(test)] + test_sink::record_verbose(event); +} + /// Emit a process lifecycle event required by the Windows diagnostics /// contract. The event kind and payload are coupled in a single enum /// to make mismatches unrepresentable at compile time. @@ -311,7 +351,7 @@ pub fn log_config_rejected( /// `mxc_telemetry` call regardless. #[cfg(test)] pub(super) mod test_sink { - use super::{ExecutionEvent, FailureReason, ProcessEvent, TelemetryContext}; + use super::{ExecutionEvent, FailureReason, ProcessEvent, TelemetryContext, VerboseEvent}; use std::cell::Cell; use std::sync::Mutex; @@ -348,6 +388,23 @@ pub(super) mod test_sink { pub correlation_vector: String, } + /// Owned copy of an `MXC.Verbose` record as captured for a test. + #[derive(Debug, Clone, PartialEq, Eq)] + pub struct CapturedVerbose { + pub backend: String, + pub sandbox_kind: String, + pub phase: String, + pub correlation_vector: String, + pub document_id: String, + pub document_version: u32, + pub chunk_index: u32, + pub chunk_count: u32, + pub document_bytes: u64, + pub document_sha256: String, + pub content: String, + pub summary: String, + } + #[derive(Debug, Clone, PartialEq, Eq)] pub struct CapturedRequirement { pub name: String, @@ -365,6 +422,7 @@ pub(super) mod test_sink { static EXECUTIONS: Mutex> = Mutex::new(Vec::new()); static ERRORS: Mutex> = Mutex::new(Vec::new()); + static VERBOSE: Mutex> = Mutex::new(Vec::new()); static REQUIREMENTS: Mutex> = Mutex::new(Vec::new()); /// Start capturing emitted records into the sink (and clear any leftovers). @@ -379,6 +437,7 @@ pub(super) mod test_sink { INSTALLED.with(|f| f.set(false)); EXECUTIONS.lock().unwrap_or_else(|e| e.into_inner()).clear(); ERRORS.lock().unwrap_or_else(|e| e.into_inner()).clear(); + VERBOSE.lock().unwrap_or_else(|e| e.into_inner()).clear(); REQUIREMENTS .lock() .unwrap_or_else(|e| e.into_inner()) @@ -395,6 +454,11 @@ pub(super) mod test_sink { std::mem::take(&mut *ERRORS.lock().unwrap_or_else(|e| e.into_inner())) } + /// Drain and return the captured `MXC.Verbose` records. + pub fn take_verbose() -> Vec { + std::mem::take(&mut *VERBOSE.lock().unwrap_or_else(|e| e.into_inner())) + } + pub fn take_requirements() -> Vec { std::mem::take(&mut *REQUIREMENTS.lock().unwrap_or_else(|e| e.into_inner())) } @@ -491,6 +555,29 @@ pub(super) mod test_sink { correlation_vector: ctx.correlation_vector.to_owned(), }); } + + pub(super) fn record_verbose(event: &VerboseEvent<'_>) { + if !INSTALLED.with(|f| f.get()) { + return; + } + VERBOSE + .lock() + .unwrap_or_else(|e| e.into_inner()) + .push(CapturedVerbose { + backend: event.backend.to_owned(), + sandbox_kind: event.sandbox_kind.to_owned(), + phase: event.phase.to_owned(), + correlation_vector: event.correlation_vector.to_owned(), + document_id: event.document_id.to_owned(), + document_version: event.document_version, + chunk_index: event.chunk_index, + chunk_count: event.chunk_count, + document_bytes: event.document_bytes, + document_sha256: event.document_sha256.to_owned(), + content: event.content.to_owned(), + summary: event.summary.to_owned(), + }); + } } #[cfg(test)] @@ -509,6 +596,37 @@ mod tests { assert_eq!(FailureReason::Unknown.as_str(), "unknown"); } + #[test] + fn verbose_event_is_captured_with_parseable_json_fields() { + let _lock = test_sink::TEST_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + test_sink::install(); + log_verbose(&VerboseEvent { + backend: "processcontainer", + sandbox_kind: "process", + phase: "", + correlation_vector: "", + document_id: "0123456789abcdef0123456789abcdef", + document_version: 2, + chunk_index: 0, + chunk_count: 1, + document_bytes: 64, + document_sha256: "abc", + content: "[]", + summary: r#"{"totalOccurrences":0}"#, + }); + + let captured = test_sink::take_verbose(); + test_sink::clear(); + assert_eq!(captured.len(), 1); + assert_eq!( + serde_json::from_str::(&captured[0].content).unwrap(), + serde_json::json!([]) + ); + assert!(serde_json::from_str::(&captured[0].summary).is_ok()); + } + #[test] fn requirement_events_use_bounded_event_names() { let _lock = test_sink::TEST_LOCK diff --git a/src/core/wxc_common/src/telemetry/mod.rs b/src/core/wxc_common/src/telemetry/mod.rs index 1d907a1709..7ad024f4f9 100644 --- a/src/core/wxc_common/src/telemetry/mod.rs +++ b/src/core/wxc_common/src/telemetry/mod.rs @@ -34,7 +34,7 @@ pub use consent::ConsentState; pub use events::{ log_config_rejected, log_enforcement_degraded, log_error, log_execution, log_network_policy_applied, log_policy_hash, log_process_event, log_sandbox_torn_down, - ExecutionEvent, FailureReason, ProcessEvent, TelemetryContext, + log_verbose, ExecutionEvent, FailureReason, ProcessEvent, TelemetryContext, VerboseEvent, }; pub use policy::PolicyState; @@ -514,6 +514,18 @@ fn emit_sdk_with_release(active: bool, emit: impl FnOnce(&EmissionAuthorization) shutdown(); } +/// Emit one verbose-document chunk under a fresh live authorization decision. +/// +/// Returns `false` when collection is no longer authorized, allowing a caller +/// to stop emitting the remaining chunks immediately. +pub fn emit_verbose(active: bool, event: &VerboseEvent<'_>) -> bool { + let Some(_auth) = EmissionAuthorization::for_invocation(active) else { + return false; + }; + log_verbose(event); + true +} + /// Record the containment backend for this process so best-effort emit paths /// that have no [`ScriptResponse`] in scope (the panic hook and the console /// control handler) can attribute their events. @@ -1283,6 +1295,37 @@ mod tests { reset_for_test(); } + #[test] + fn verbose_chunks_recheck_live_authorization() { + let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + reset_for_test(); + events::test_sink::install(); + TEST_FORCE_ACTIVE.with(|active| active.set(true)); + let event = VerboseEvent { + backend: "processcontainer", + sandbox_kind: "process", + phase: "", + correlation_vector: "", + document_id: "0123456789abcdef0123456789abcdef", + document_version: 2, + chunk_index: 0, + chunk_count: 1, + document_bytes: 64, + document_sha256: "abc", + content: "[]", + summary: r#"{"totalOccurrences":0}"#, + }; + + TEST_AUTHORIZATION_OVERRIDE.with(|allowed| allowed.set(Some(false))); + assert!(!emit_verbose(true, &event)); + assert!(events::test_sink::take_verbose().is_empty()); + + TEST_AUTHORIZATION_OVERRIDE.with(|allowed| allowed.set(Some(true))); + assert!(emit_verbose(true, &event)); + assert_eq!(events::test_sink::take_verbose().len(), 1); + reset_for_test(); + } + #[test] fn emit_panic_active_captures_execution_and_error() { // Drive the real emit glue (globals read → active guard → paired write) diff --git a/src/mxc_telemetry/src/lib.rs b/src/mxc_telemetry/src/lib.rs index d74ec5825a..c39b218d12 100644 --- a/src/mxc_telemetry/src/lib.rs +++ b/src/mxc_telemetry/src/lib.rs @@ -278,6 +278,63 @@ mod provider { str8("__TlgCV__", correlation_vector), ); } + + /// Emit one independently parseable chunk of a Learning Mode verbose + /// logging document. + #[allow(clippy::too_many_arguments)] + pub fn log_verbose( + backend: &str, + sandbox_kind: &str, + phase: &str, + correlation_vector: &str, + document_id: &str, + document_version: u32, + chunk_index: u32, + chunk_count: u32, + document_bytes: u64, + document_sha256: &str, + content: &str, + summary: &str, + ) { + let state = match STATE.get() { + Some(s) => s, + None => return, + }; + + let is_debug_build = cfg!(debug_assertions); + + tracelogging::write_event!( + MXC_PROVIDER, + "MXC.Verbose", + level(Informational), + keyword(MXC_EVENT_KEYWORD), + u16("PartA_PrivacyProduct", &PRIVACY_PRODUCT_MXC), + u16( + "PartA_PrivacyDataCategory", + &PRIVACY_DATA_CATEGORY_CLIENT_DIAGNOSTIC_DATA, + ), + u64("PartA_PrivTags", &MXC_PRIVACY_TAG), + struct("COMMON_MXC_PARAMS", { + str8("Version", &state.version), + str8("Channel", &state.channel), + bool8("IsDebugging", &is_debug_build), + bool8("UTCReplace_AppSessionGuid", &true), + }), + str8("mxc.backend", backend), + str8("mxc.sandbox_kind", sandbox_kind), + str8("mxc.phase", phase), + str8("__TlgCV__", correlation_vector), + str8("mxc.document_id", document_id), + u32("mxc.document_version", &document_version), + u32("mxc.chunk_index", &chunk_index), + u32("mxc.chunk_count", &chunk_count), + u64("mxc.document_bytes", &document_bytes), + str8("mxc.document_sha256", document_sha256), + str8("mxc.content", content), + str8("mxc.summary", summary), + ); + } + fn common_params(state: &ProviderState) -> (&str, &str, bool, bool) { (&state.version, &state.channel, cfg!(debug_assertions), true) } @@ -560,6 +617,22 @@ mod provider { _correlation_vector: &str, ) { } + #[allow(clippy::too_many_arguments)] + pub fn log_verbose( + _backend: &str, + _sandbox_kind: &str, + _phase: &str, + _correlation_vector: &str, + _document_id: &str, + _document_version: u32, + _chunk_index: u32, + _chunk_count: u32, + _document_bytes: u64, + _document_sha256: &str, + _content: &str, + _summary: &str, + ) { + } pub fn log_process_exited(_identity: &str, _process_id: u32, _exit_code: i32) {} pub fn log_process_timed_out(_identity: &str, _process_id: u32, _timeout_ms: u64) {} pub fn log_process_kill_failed( @@ -626,6 +699,10 @@ mod tests { provider_source.contains("\"MXC.Error\","), "error event identity changed" ); + assert!( + provider_source.contains("\"MXC.Verbose\","), + "verbose event identity changed" + ); let error_section = provider_source .split("\"MXC.Error\",") .nth(1) @@ -655,28 +732,28 @@ mod tests { provider_source .matches("u16(\"PartA_PrivacyProduct\", &PRIVACY_PRODUCT_MXC)") .count(), - 10, + 11, "every MXC event must carry privacy product 11" ); assert_eq!( provider_source .matches("\"PartA_PrivacyDataCategory\"") .count(), - 10, + 11, "every MXC event must carry a privacy data category" ); assert_eq!( provider_source .matches("&PRIVACY_DATA_CATEGORY_CLIENT_DIAGNOSTIC_DATA") .count(), - 10, + 11, "every MXC event must use Client Diagnostic Data category 1" ); assert_eq!( provider_source .matches("u64(\"PartA_PrivTags\", &MXC_PRIVACY_TAG)") .count(), - 10, + 11, "every MXC event must retain its approved privacy tag" ); } @@ -761,6 +838,27 @@ mod tests { shutdown(); } + #[test] + fn log_verbose_after_init() { + let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _ = init("0.0.0-test", "dev"); + log_verbose( + "processcontainer", + "process", + "", + "", + "0123456789abcdef0123456789abcdef", + 2, + 0, + 1, + 64, + "abc", + "[]", + r#"{"totalOccurrences":0}"#, + ); + shutdown(); + } + #[test] fn log_without_init() { let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); @@ -775,6 +873,7 @@ mod tests { "", ); log_error("test_backend", "test_backend", "unknown", 1, "", ""); + log_verbose("", "", "", "", "", 0, 0, 0, 0, "", "[]", "{}"); } #[test] @@ -808,6 +907,7 @@ mod tests { let _ = init("", ""); log_execution("", "", 0, "", 0, "", "", ""); log_error("", "", "", 0, "", ""); + log_verbose("", "", "", "", "", 0, 0, 0, 0, "", "[]", "{}"); shutdown(); } diff --git a/src/testing/wxc_e2e_tests/tests/e2e_telemetry_etw.rs b/src/testing/wxc_e2e_tests/tests/e2e_telemetry_etw.rs index 74ddee5170..68dd15b109 100644 --- a/src/testing/wxc_e2e_tests/tests/e2e_telemetry_etw.rs +++ b/src/testing/wxc_e2e_tests/tests/e2e_telemetry_etw.rs @@ -190,7 +190,7 @@ fn seed_consent(local_app_data: &Path, state: &str) { std::fs::create_dir_all(&dir).expect("failed to create consent directory"); let record = format!( r#"{{"schemaVersion":2,"consent":"{state}","source":"telemetry-etw-e2e", -"promptedMxcVersion":"0.0.0-e2e","promptResourceVersion":1, +"promptedMxcVersion":"0.0.0-e2e","promptResourceVersion":2, "promptLocale":"en-US","updatedAtEpoch":0}}"# ); std::fs::write(dir.join("telemetry-consent.json"), record.replace('\n', "")) diff --git a/tests/scripts/run_telemetry_consent_smoke_test.ps1 b/tests/scripts/run_telemetry_consent_smoke_test.ps1 index f4a837471e..aa7634e423 100644 --- a/tests/scripts/run_telemetry_consent_smoke_test.ps1 +++ b/tests/scripts/run_telemetry_consent_smoke_test.ps1 @@ -187,7 +187,7 @@ try { consent = $seed source = 'smoke-proof' promptedMxcVersion = '0.0.0-smoke' - promptResourceVersion = 1 + promptResourceVersion = 2 promptLocale = 'en-US' updatedAtEpoch = 0 } diff --git a/tests/scripts/run_telemetry_etw_smoke_test.ps1 b/tests/scripts/run_telemetry_etw_smoke_test.ps1 index 5fe6dcf269..5a52a176ca 100644 --- a/tests/scripts/run_telemetry_etw_smoke_test.ps1 +++ b/tests/scripts/run_telemetry_etw_smoke_test.ps1 @@ -163,7 +163,7 @@ try { consent = $seed source = 'etw-smoke-proof' promptedMxcVersion = '0.0.0-smoke' - promptResourceVersion = 1 + promptResourceVersion = 2 promptLocale = 'en-US' updatedAtEpoch = 0 } From d53121c6f0e2edffbed502e5764d6c3769a45fe7 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Fri, 28 Aug 2026 10:19:16 -0700 Subject: [PATCH 2/9] Surface verbose telemetry ETW write failures Propagate EventWriteTransfer status codes through the verbose telemetry path so rejected chunks produce a bounded local diagnostic instead of failing silently. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e32b4c9-eb47-4231-91de-0da5fde17ab7 --- src/core/mxc_engine/src/verbose_telemetry.rs | 10 ++++++++-- src/core/wxc_common/src/telemetry/events.rs | 6 ++++-- src/core/wxc_common/src/telemetry/mod.rs | 14 ++++++++------ src/mxc_telemetry/src/lib.rs | 9 +++++---- 4 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/core/mxc_engine/src/verbose_telemetry.rs b/src/core/mxc_engine/src/verbose_telemetry.rs index 5893071d5a..c7a8fe5c1b 100644 --- a/src/core/mxc_engine/src/verbose_telemetry.rs +++ b/src/core/mxc_engine/src/verbose_telemetry.rs @@ -52,7 +52,7 @@ pub fn emit_verbose_telemetry( for (index, content) in prepared.chunks.iter().enumerate() { let chunk_index = u32::try_from(index) .map_err(|_| "verbose artifact chunk index exceeded u32".to_string())?; - if !telemetry::emit_verbose( + match telemetry::emit_verbose( true, &VerboseEvent { backend, @@ -69,7 +69,13 @@ pub fn emit_verbose_telemetry( summary: &prepared.summary, }, ) { - break; + Ok(true) => {} + Ok(false) => break, + Err(status) => { + return Err(format!( + "ETW rejected a verbose telemetry chunk with status {status}" + )); + } } } Ok(()) diff --git a/src/core/wxc_common/src/telemetry/events.rs b/src/core/wxc_common/src/telemetry/events.rs index c8e4e8b414..37f4801d1e 100644 --- a/src/core/wxc_common/src/telemetry/events.rs +++ b/src/core/wxc_common/src/telemetry/events.rs @@ -153,8 +153,8 @@ pub fn log_error( } /// Log one `MXC.Verbose` ETW event. -pub fn log_verbose(event: &VerboseEvent<'_>) { - mxc_telemetry::log_verbose( +pub fn log_verbose(event: &VerboseEvent<'_>) -> u32 { + let status = mxc_telemetry::log_verbose( event.backend, event.sandbox_kind, event.phase, @@ -171,6 +171,8 @@ pub fn log_verbose(event: &VerboseEvent<'_>) { #[cfg(test)] test_sink::record_verbose(event); + + status } /// Emit a process lifecycle event required by the Windows diagnostics diff --git a/src/core/wxc_common/src/telemetry/mod.rs b/src/core/wxc_common/src/telemetry/mod.rs index 7ad024f4f9..74a83d35ec 100644 --- a/src/core/wxc_common/src/telemetry/mod.rs +++ b/src/core/wxc_common/src/telemetry/mod.rs @@ -518,12 +518,14 @@ fn emit_sdk_with_release(active: bool, emit: impl FnOnce(&EmissionAuthorization) /// /// Returns `false` when collection is no longer authorized, allowing a caller /// to stop emitting the remaining chunks immediately. -pub fn emit_verbose(active: bool, event: &VerboseEvent<'_>) -> bool { +pub fn emit_verbose(active: bool, event: &VerboseEvent<'_>) -> Result { let Some(_auth) = EmissionAuthorization::for_invocation(active) else { - return false; + return Ok(false); }; - log_verbose(event); - true + match log_verbose(event) { + 0 => Ok(true), + status => Err(status), + } } /// Record the containment backend for this process so best-effort emit paths @@ -1317,11 +1319,11 @@ mod tests { }; TEST_AUTHORIZATION_OVERRIDE.with(|allowed| allowed.set(Some(false))); - assert!(!emit_verbose(true, &event)); + assert!(!emit_verbose(true, &event).unwrap()); assert!(events::test_sink::take_verbose().is_empty()); TEST_AUTHORIZATION_OVERRIDE.with(|allowed| allowed.set(Some(true))); - assert!(emit_verbose(true, &event)); + assert!(emit_verbose(true, &event).unwrap()); assert_eq!(events::test_sink::take_verbose().len(), 1); reset_for_test(); } diff --git a/src/mxc_telemetry/src/lib.rs b/src/mxc_telemetry/src/lib.rs index c39b218d12..b4b112a999 100644 --- a/src/mxc_telemetry/src/lib.rs +++ b/src/mxc_telemetry/src/lib.rs @@ -295,10 +295,10 @@ mod provider { document_sha256: &str, content: &str, summary: &str, - ) { + ) -> u32 { let state = match STATE.get() { Some(s) => s, - None => return, + None => return 0, }; let is_debug_build = cfg!(debug_assertions); @@ -332,7 +332,7 @@ mod provider { str8("mxc.document_sha256", document_sha256), str8("mxc.content", content), str8("mxc.summary", summary), - ); + ) } fn common_params(state: &ProviderState) -> (&str, &str, bool, bool) { @@ -631,7 +631,8 @@ mod provider { _document_sha256: &str, _content: &str, _summary: &str, - ) { + ) -> u32 { + 0 } pub fn log_process_exited(_identity: &str, _process_id: u32, _exit_code: i32) {} pub fn log_process_timed_out(_identity: &str, _process_id: u32, _timeout_ms: u64) {} From 48c4fa003a6818f34cd694654b956fe6798569a5 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Fri, 28 Aug 2026 10:26:05 -0700 Subject: [PATCH 3/9] Rename verbose denials telemetry event Use MXC.VerboseDenials as the ETW identity so the event's Learning Mode denial payload is clear to telemetry consumers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e32b4c9-eb47-4231-91de-0da5fde17ab7 --- README.md | 6 +++--- docs/learning-mode/capabilities.md | 2 +- docs/telemetry/telemetry-consent-design.md | 2 +- docs/telemetry/telemetry.md | 4 ++-- src/core/wxc_common/src/telemetry/events.rs | 6 +++--- src/mxc_telemetry/src/lib.rs | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 81f2b8bfd0..07d910a318 100644 --- a/README.md +++ b/README.md @@ -231,7 +231,7 @@ Successful non-dry-run audits require capture metadata, actionable denials JSON, ## Telemetry -MXC supports optional TraceLogging ETW telemetry for execution observability. When enabled, structured events (`MXC.Execution`, `MXC.Error`, and the sanitized Learning Mode artifact event `MXC.Verbose`) are emitted to the local ETW subsystem via the Rust [`tracelogging`](https://crates.io/crates/tracelogging) crate. Every event includes common fields (Version, Channel, IsDebugging, `UTCReplace_AppSessionGuid`) as Part C custom event data. +MXC supports optional TraceLogging ETW telemetry for execution observability. When enabled, structured events (`MXC.Execution`, `MXC.Error`, and the sanitized Learning Mode artifact event `MXC.VerboseDenials`) are emitted by the `Microsoft.MXC` provider to the local ETW subsystem via the Rust [`tracelogging`](https://crates.io/crates/tracelogging) crate. Every event includes common fields (Version, Channel, IsDebugging, `UTCReplace_AppSessionGuid`) as Part C custom event data. Telemetry requires: 1. Top-level `"telemetry": { "enabled": true }` in the JSON config @@ -259,9 +259,9 @@ Windows user consent is granted and administrative policy allows collection. #### What official builds send -Official/shipped Microsoft builds set a TraceLogging provider group GUID at build time and route `MXC.Execution`, `MXC.Error`, and `MXC.Verbose` events to Microsoft through the UTC pipeline when telemetry is enabled — that same build-time setting also selects the correct Measures keyword and Product-and-Service-Usage privacy tag for the events, so telemetry routing and event classification always agree. **Local and open-source builds send nothing to Microsoft by default** — the public source ships without a provider group GUID, so events are emitted to the local ETW subsystem only, use a provider-local keyword with no UTC meaning, and carry no privacy classification tag, and are not routed to any Microsoft collection pipeline. Internal builds that set the `MXC_TELEMETRY_PROVIDER_GROUP_GUID` environment variable at build time enable the Microsoft-routed path. +Official/shipped Microsoft builds set a TraceLogging provider group GUID at build time and route `MXC.Execution`, `MXC.Error`, and `MXC.VerboseDenials` events to Microsoft through the UTC pipeline when telemetry is enabled — that same build-time setting also selects the correct Measures keyword and Product-and-Service-Usage privacy tag for the events, so telemetry routing and event classification always agree. **Local and open-source builds send nothing to Microsoft by default** — the public source ships without a provider group GUID, so events are emitted to the local ETW subsystem only, use a provider-local keyword with no UTC meaning, and carry no privacy classification tag, and are not routed to any Microsoft collection pipeline. Internal builds that set the `MXC_TELEMETRY_PROVIDER_GROUP_GUID` environment variable at build time enable the Microsoft-routed path. -No PII is collected. Execution/error events contain only execution metrics (duration, backend type, exit code) and a bounded error category (`error_type`). When a ProcessContainer run successfully produces a Learning Mode verbose artifact, `MXC.Verbose` can include its sanitized technical signatures. It excludes commands, credentials, complete file paths, usernames, sandbox output, raw ETL, actionable denial documents, general logger text, and free-form error text. The version 2 consent wording and verbose-event data inventory require explicit privacy/release review before shipment. If you use the SDK to build applications, you are responsible for providing appropriate telemetry notices to your own users. +No PII is collected. Execution/error events contain only execution metrics (duration, backend type, exit code) and a bounded error category (`error_type`). When a ProcessContainer run successfully produces a Learning Mode verbose artifact, `MXC.VerboseDenials` can include its sanitized technical signatures. It excludes commands, credentials, complete file paths, usernames, sandbox output, raw ETL, actionable denial documents, general logger text, and free-form error text. The version 2 consent wording and verbose-event data inventory require explicit privacy/release review before shipment. If you use the SDK to build applications, you are responsible for providing appropriate telemetry notices to your own users. Privacy information can be found at https://privacy.microsoft.com and in the Microsoft privacy statement at https://go.microsoft.com/fwlink/?LinkID=824704. diff --git a/docs/learning-mode/capabilities.md b/docs/learning-mode/capabilities.md index c85f96ebf4..841de0a9fc 100644 --- a/docs/learning-mode/capabilities.md +++ b/docs/learning-mode/capabilities.md @@ -332,7 +332,7 @@ metadata; callers derive it from the actionable output path using the naming rul above. When stable telemetry is enabled and authorized, MXC may validate, compact, and -send this redacted verbose document through `Microsoft.MXC/MXC.Verbose`. Each +send this redacted verbose document through `Microsoft.MXC/MXC.VerboseDenials`. Each event contains a valid JSON array of complete signatures and document reconstruction metadata. MXC does not send the actionable denials file or raw ETL through telemetry. See [MXC telemetry](../telemetry/telemetry.md). diff --git a/docs/telemetry/telemetry-consent-design.md b/docs/telemetry/telemetry-consent-design.md index f0cb7b1930..f32966e433 100644 --- a/docs/telemetry/telemetry-consent-design.md +++ b/docs/telemetry/telemetry-consent-design.md @@ -195,7 +195,7 @@ The public ETW event identities remain: - `MXC.Execution` - `MXC.Error` -- `MXC.Verbose` +- `MXC.VerboseDenials` Collectors may continue filtering those names; consent stabilization does not rename the provider or events. diff --git a/docs/telemetry/telemetry.md b/docs/telemetry/telemetry.md index 03dfd6a2a3..bf354d1af0 100644 --- a/docs/telemetry/telemetry.md +++ b/docs/telemetry/telemetry.md @@ -147,7 +147,7 @@ targets for this work. The provider-qualified event identities include `Microsoft.MXC/MXC.Execution`, `Microsoft.MXC/MXC.Error`, and -`Microsoft.MXC/MXC.Verbose`. `Microsoft.MXC` is the TraceLogging provider +`Microsoft.MXC/MXC.VerboseDenials`. `Microsoft.MXC` is the TraceLogging provider name. The M-ETW requirement events listed above share that provider. ### MXC.Execution @@ -193,7 +193,7 @@ Emitted on execution errors. > attribution fields, the `error_type` category, and the numeric `exit_code` — > never the message string itself. -### MXC.Verbose +### MXC.VerboseDenials Emitted when a telemetry-enabled ProcessContainer run successfully produces a Learning Mode `captureDenials` verbose logging artifact. MXC reads the diff --git a/src/core/wxc_common/src/telemetry/events.rs b/src/core/wxc_common/src/telemetry/events.rs index 37f4801d1e..52901f75fd 100644 --- a/src/core/wxc_common/src/telemetry/events.rs +++ b/src/core/wxc_common/src/telemetry/events.rs @@ -152,7 +152,7 @@ pub fn log_error( test_sink::record_error(ctx, sandbox_kind, error_type, exit_code); } -/// Log one `MXC.Verbose` ETW event. +/// Log one `MXC.VerboseDenials` ETW event. pub fn log_verbose(event: &VerboseEvent<'_>) -> u32 { let status = mxc_telemetry::log_verbose( event.backend, @@ -390,7 +390,7 @@ pub(super) mod test_sink { pub correlation_vector: String, } - /// Owned copy of an `MXC.Verbose` record as captured for a test. + /// Owned copy of an `MXC.VerboseDenials` record as captured for a test. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CapturedVerbose { pub backend: String, @@ -456,7 +456,7 @@ pub(super) mod test_sink { std::mem::take(&mut *ERRORS.lock().unwrap_or_else(|e| e.into_inner())) } - /// Drain and return the captured `MXC.Verbose` records. + /// Drain and return the captured `MXC.VerboseDenials` records. pub fn take_verbose() -> Vec { std::mem::take(&mut *VERBOSE.lock().unwrap_or_else(|e| e.into_inner())) } diff --git a/src/mxc_telemetry/src/lib.rs b/src/mxc_telemetry/src/lib.rs index b4b112a999..beaa99a456 100644 --- a/src/mxc_telemetry/src/lib.rs +++ b/src/mxc_telemetry/src/lib.rs @@ -305,7 +305,7 @@ mod provider { tracelogging::write_event!( MXC_PROVIDER, - "MXC.Verbose", + "MXC.VerboseDenials", level(Informational), keyword(MXC_EVENT_KEYWORD), u16("PartA_PrivacyProduct", &PRIVACY_PRODUCT_MXC), @@ -701,7 +701,7 @@ mod tests { "error event identity changed" ); assert!( - provider_source.contains("\"MXC.Verbose\","), + provider_source.contains("\"MXC.VerboseDenials\","), "verbose event identity changed" ); let error_section = provider_source From 52190a10d139060988188a71dd1088429edb9fc9 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Tue, 1 Sep 2026 15:33:22 -0700 Subject: [PATCH 4/9] Update telemetry consent language Apply the privacy-reviewed Microsoft Products disclosure and bump the consent resource version so existing grants require renewed consent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ba4f8739-616b-4b42-8eba-24a762602400 --- docs/telemetry/telemetry-consent-design.md | 14 +++++++++----- .../resources/telemetry/consent/en-US.json | 6 +++--- .../wxc_common/src/telemetry/consent_prompt.rs | 18 ++++++------------ .../wxc_e2e_tests/tests/e2e_telemetry_etw.rs | 2 +- .../run_telemetry_consent_smoke_test.ps1 | 10 +++++----- tests/scripts/run_telemetry_etw_smoke_test.ps1 | 10 +++++----- 6 files changed, 29 insertions(+), 31 deletions(-) diff --git a/docs/telemetry/telemetry-consent-design.md b/docs/telemetry/telemetry-consent-design.md index f32966e433..55529e05d4 100644 --- a/docs/telemetry/telemetry-consent-design.md +++ b/docs/telemetry/telemetry-consent-design.md @@ -5,6 +5,10 @@ MXC ships the canonical consent resource, persistent consent storage, and consent-management APIs described below. Telemetry remains off by default and is enabled only when every gate in this contract permits collection. +The purpose of the MXC Learning Mode Data Loop is to analyze collected data to +identify usage patterns, improve Windows security, and inform MXC about +application compatibility in containers with minimal capabilities. + MXC telemetry is Windows-only and fails closed. Collection requires all four conditions at the time each event is written: @@ -30,7 +34,7 @@ native UI, but may not substitute wording. Current resource: -- Resource version: `2` +- Resource version: `3` - Locale: `en-US` - Mandatory fallback: `en-US` - Privacy link: @@ -38,15 +42,15 @@ Current resource: ### Title ```text -Help improve Microsoft eXecution Container (MXC) +Help improve Microsoft Products ``` ### Body ```text -Help improve MXC by sharing optional diagnostic data with Microsoft. -If enabled, MXC sends diagnostic information about product usage, performance, and reliability. When Learning Mode capture is used, this can include sanitized technical details about resource-access events, such as provider and event identifiers, process IDs, access classifications, and bounded redacted properties. MXC does not send your commands, credentials, complete file paths, usernames, or sandbox output. -You can change your choice at any time. +Help improve MXC and other Microsoft product including Windows by sharing optional diagnostic data with Microsoft. + +If enabled, MXC sends diagnostic information about product usage, performance, and reliability. MXC does not send your commands, file paths, credentials, or other customer content. ``` ### Actions diff --git a/src/core/wxc_common/resources/telemetry/consent/en-US.json b/src/core/wxc_common/resources/telemetry/consent/en-US.json index f2448c1d48..f8c20e8563 100644 --- a/src/core/wxc_common/resources/telemetry/consent/en-US.json +++ b/src/core/wxc_common/resources/telemetry/consent/en-US.json @@ -1,14 +1,14 @@ { - "resourceVersion": 2, + "resourceVersion": 3, "locale": "en-US", "messages": { "title": { "id": "telemetry-consent-title", - "text": "Help improve Microsoft eXecution Container (MXC)" + "text": "Help improve Microsoft Products" }, "body": { "id": "telemetry-consent-body", - "text": "Help improve MXC by sharing optional diagnostic data with Microsoft.\nIf enabled, MXC sends diagnostic information about product usage, performance, and reliability. When Learning Mode capture is used, this can include sanitized technical details about resource-access events, such as provider and event identifiers, process IDs, access classifications, and bounded redacted properties. MXC does not send your commands, credentials, complete file paths, usernames, or sandbox output.\nYou can change your choice at any time." + "text": "Help improve MXC and other Microsoft product including Windows by sharing optional diagnostic data with Microsoft.\n\nIf enabled, MXC sends diagnostic information about product usage, performance, and reliability. MXC does not send your commands, file paths, credentials, or other customer content." }, "affirmativeLabel": { "id": "telemetry-consent-affirmative-label", diff --git a/src/core/wxc_common/src/telemetry/consent_prompt.rs b/src/core/wxc_common/src/telemetry/consent_prompt.rs index 9839d90dd1..c0db3c8052 100644 --- a/src/core/wxc_common/src/telemetry/consent_prompt.rs +++ b/src/core/wxc_common/src/telemetry/consent_prompt.rs @@ -66,20 +66,14 @@ mod tests { let prompt = prompt_for_locale(Some("en-US")); assert_eq!(prompt.resource_version, CONSENT_RESOURCE_VERSION); assert_eq!(prompt.locale, "en-US"); - assert_eq!( - prompt.title.text, - "Help improve Microsoft eXecution Container (MXC)" - ); + assert_eq!(prompt.title.text, "Help improve Microsoft Products"); assert_eq!( prompt.body.text, - "Help improve MXC by sharing optional diagnostic data with Microsoft.\n\ - If enabled, MXC sends diagnostic information about product usage, performance, \ - and reliability. When Learning Mode capture is used, this can include sanitized \ - technical details about resource-access events, such as provider and event \ - identifiers, process IDs, access classifications, and bounded redacted properties. \ - MXC does not send your commands, credentials, complete file paths, usernames, or \ - sandbox output.\n\ - You can change your choice at any time." + "Help improve MXC and other Microsoft product including Windows by sharing optional \ + diagnostic data with Microsoft.\n\n\ + If enabled, MXC sends diagnostic information about product usage, performance, and \ + reliability. MXC does not send your commands, file paths, credentials, or other \ + customer content." ); assert_eq!(prompt.affirmative_label.text, "Yes"); assert_eq!(prompt.negative_label.text, "No"); diff --git a/src/testing/wxc_e2e_tests/tests/e2e_telemetry_etw.rs b/src/testing/wxc_e2e_tests/tests/e2e_telemetry_etw.rs index 68dd15b109..a56c205100 100644 --- a/src/testing/wxc_e2e_tests/tests/e2e_telemetry_etw.rs +++ b/src/testing/wxc_e2e_tests/tests/e2e_telemetry_etw.rs @@ -190,7 +190,7 @@ fn seed_consent(local_app_data: &Path, state: &str) { std::fs::create_dir_all(&dir).expect("failed to create consent directory"); let record = format!( r#"{{"schemaVersion":2,"consent":"{state}","source":"telemetry-etw-e2e", -"promptedMxcVersion":"0.0.0-e2e","promptResourceVersion":2, +"promptedMxcVersion":"0.0.0-e2e","promptResourceVersion":3, "promptLocale":"en-US","updatedAtEpoch":0}}"# ); std::fs::write(dir.join("telemetry-consent.json"), record.replace('\n', "")) diff --git a/tests/scripts/run_telemetry_consent_smoke_test.ps1 b/tests/scripts/run_telemetry_consent_smoke_test.ps1 index aa7634e423..103a4c15e8 100644 --- a/tests/scripts/run_telemetry_consent_smoke_test.ps1 +++ b/tests/scripts/run_telemetry_consent_smoke_test.ps1 @@ -40,9 +40,9 @@ function Assert-ConsentParseExitCodes { } $expectedBody = @' -Help improve MXC by sharing optional diagnostic data with Microsoft. +Help improve MXC and other Microsoft product including Windows by sharing optional diagnostic data with Microsoft. + If enabled, MXC sends diagnostic information about product usage, performance, and reliability. MXC does not send your commands, file paths, credentials, or other customer content. -You can change your choice at any time. '@ -replace "`r`n", "`n" function Invoke-Maintenance([string]$Action) { @@ -76,9 +76,9 @@ function Invoke-ConsentRequest([string]$Decision) { if (-not $first.prompt -or -not $first.challenge) { throw "Unexpected consent presentation: $firstLine" } - if ($first.prompt.resourceVersion -ne 1 -or + if ($first.prompt.resourceVersion -ne 3 -or $first.prompt.locale -ne 'en-US' -or - $first.prompt.title.text -ne 'Help improve Microsoft eXecution Container (MXC)' -or + $first.prompt.title.text -ne 'Help improve Microsoft Products' -or $first.prompt.body.text -ne $expectedBody -or $first.prompt.affirmativeLabel.text -ne 'Yes' -or $first.prompt.negativeLabel.text -ne 'No' -or @@ -187,7 +187,7 @@ try { consent = $seed source = 'smoke-proof' promptedMxcVersion = '0.0.0-smoke' - promptResourceVersion = 2 + promptResourceVersion = 3 promptLocale = 'en-US' updatedAtEpoch = 0 } diff --git a/tests/scripts/run_telemetry_etw_smoke_test.ps1 b/tests/scripts/run_telemetry_etw_smoke_test.ps1 index 5a52a176ca..4eb1b93d51 100644 --- a/tests/scripts/run_telemetry_etw_smoke_test.ps1 +++ b/tests/scripts/run_telemetry_etw_smoke_test.ps1 @@ -33,9 +33,9 @@ if ($wxcExe -match '\\release\\') { throw 'Release binaries are refused: telemetry smoke isolation depends on debug-only test overrides.' } $expectedBody = @' -Help improve MXC by sharing optional diagnostic data with Microsoft. +Help improve MXC and other Microsoft product including Windows by sharing optional diagnostic data with Microsoft. + If enabled, MXC sends diagnostic information about product usage, performance, and reliability. MXC does not send your commands, file paths, credentials, or other customer content. -You can change your choice at any time. '@ -replace "`r`n", "`n" $configFile = Join-Path $repoRoot 'tests\examples\28_telemetry_enabled.json' @@ -92,9 +92,9 @@ function Invoke-CanonicalGrant { if ($presentation.result -ne 'presentationRequired' -or -not $presentation.challenge -or -not $presentation.prompt) { throw "Unexpected consent presentation: $presentationLine" } - if ($presentation.prompt.resourceVersion -ne 1 -or + if ($presentation.prompt.resourceVersion -ne 3 -or $presentation.prompt.locale -ne 'en-US' -or - $presentation.prompt.title.text -ne 'Help improve Microsoft eXecution Container (MXC)' -or + $presentation.prompt.title.text -ne 'Help improve Microsoft Products' -or $presentation.prompt.body.text -ne $expectedBody -or $presentation.prompt.affirmativeLabel.text -ne 'Yes' -or $presentation.prompt.negativeLabel.text -ne 'No' -or @@ -163,7 +163,7 @@ try { consent = $seed source = 'etw-smoke-proof' promptedMxcVersion = '0.0.0-smoke' - promptResourceVersion = 2 + promptResourceVersion = 3 promptLocale = 'en-US' updatedAtEpoch = 0 } From 199c75d840b5ff53e9a9d9dc49cc7d09ab2630e6 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Tue, 1 Sep 2026 16:20:41 -0700 Subject: [PATCH 5/9] Address verbose telemetry review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ba4f8739-616b-4b42-8eba-24a762602400 --- README.md | 15 +- docs/telemetry/telemetry.md | 14 +- src/core/mxc-sdk/src/sandbox.rs | 10 +- src/core/mxc_engine/src/lib.rs | 174 +++++++++++++----- src/host/plm/readme.md | 2 +- .../wxc_e2e_tests/tests/e2e_telemetry_etw.rs | 144 +++++++++++++++ 6 files changed, 309 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 07d910a318..7bcb23191b 100644 --- a/README.md +++ b/README.md @@ -225,7 +225,7 @@ See [docs/diagnostics.md](docs/diagnostics.md) for full diagnostics reference. wxc-exec.exe --audit policy.json ``` -Successful non-dry-run audits require capture metadata, actionable denials JSON, and a retained ETL. The CLI relocates the backend-selected paths to `denials.json` and `trace.etl` in the per-user audit directory, then generates a source-config snapshot and `Adjusted_*.json` from the actionable JSON without decoding the ETL again. Base64-only input keeps JSON and ETL but has no source config to snapshot or adjust. Truncated analysis keeps JSON, ETL, and the source snapshot but skips adjusted-config generation. Use `--audit-verbose` to print learned-policy details. +Successful non-dry-run audits require capture metadata, actionable denials JSON, its verbose diagnostic sibling, and a retained ETL. The CLI relocates the backend-selected paths to `denials.json`, `denials.verbose.json`, and `trace.etl` in the per-user audit directory, then generates a source-config snapshot and `Adjusted_*.json` from the actionable JSON without decoding the ETL again. Base64-only input keeps both JSON files and the ETL but has no source config to snapshot or adjust. Truncated analysis keeps both JSON files, the ETL, and the source snapshot but skips adjusted-config generation. Use `--audit-verbose` to print learned-policy details. > **Warning:** `--audit` injects `permissiveLearningMode` — AppContainer restrictions are **not** enforced for the duration of the run. Use only for policy authoring. It cannot be combined with `processContainer.captureDenials`; use `captureDenials.mode: "allow"` for permissive application-driven capture. `learningModeLogging` and `permissiveLearningMode` are reserved internal capability names and are rejected in `processContainer.capabilities`. See [docs/learning-mode/capabilities.md](docs/learning-mode/capabilities.md) for the three learning-mode flows. @@ -261,7 +261,18 @@ Windows user consent is granted and administrative policy allows collection. Official/shipped Microsoft builds set a TraceLogging provider group GUID at build time and route `MXC.Execution`, `MXC.Error`, and `MXC.VerboseDenials` events to Microsoft through the UTC pipeline when telemetry is enabled — that same build-time setting also selects the correct Measures keyword and Product-and-Service-Usage privacy tag for the events, so telemetry routing and event classification always agree. **Local and open-source builds send nothing to Microsoft by default** — the public source ships without a provider group GUID, so events are emitted to the local ETW subsystem only, use a provider-local keyword with no UTC meaning, and carry no privacy classification tag, and are not routed to any Microsoft collection pipeline. Internal builds that set the `MXC_TELEMETRY_PROVIDER_GROUP_GUID` environment variable at build time enable the Microsoft-routed path. -No PII is collected. Execution/error events contain only execution metrics (duration, backend type, exit code) and a bounded error category (`error_type`). When a ProcessContainer run successfully produces a Learning Mode verbose artifact, `MXC.VerboseDenials` can include its sanitized technical signatures. It excludes commands, credentials, complete file paths, usernames, sandbox output, raw ETL, actionable denial documents, general logger text, and free-form error text. The version 2 consent wording and verbose-event data inventory require explicit privacy/release review before shipment. If you use the SDK to build applications, you are responsible for providing appropriate telemetry notices to your own users. +No PII is collected. Execution/error events contain only execution metrics +(duration, backend type, exit code) and a bounded error category +(`error_type`). When a ProcessContainer run successfully produces a Learning +Mode verbose artifact, `MXC.VerboseDenials` can include sanitized +provider/event identifiers, process IDs, closed outcome reasons, +access/resource classifications, bounded redacted properties, occurrence +counts, and truncation state. It excludes commands, credentials, complete file +paths, usernames, sandbox output, raw ETL, actionable denial documents, +general logger text, and free-form error text. The verbose-event data inventory +requires explicit privacy/release review before shipment. If you use the SDK +to build applications, you are responsible for providing appropriate telemetry +notices to your own users. Privacy information can be found at https://privacy.microsoft.com and in the Microsoft privacy statement at https://go.microsoft.com/fwlink/?LinkID=824704. diff --git a/docs/telemetry/telemetry.md b/docs/telemetry/telemetry.md index bf354d1af0..1eb949ccda 100644 --- a/docs/telemetry/telemetry.md +++ b/docs/telemetry/telemetry.md @@ -250,10 +250,11 @@ free-form error text. ### Privacy review status -The version 2 consent wording, verbose-event inventory, current WinExt -classification, retention, access, regional processing, deletion, and -localization/accessibility decisions require explicit privacy/release review. -The canonical wording and presenter contract are documented in +The canonical version 3 `en-US` consent wording is privacy-reviewed. The +verbose-event inventory, current WinExt classification, retention, access, +regional processing, deletion, and localization/accessibility decisions +require explicit privacy/release review. The canonical wording and presenter +contract are documented in [Telemetry consent design](telemetry-consent-design.md). ### Correlating a lifecycle @@ -386,6 +387,11 @@ administrative policy, and provider availability. See [`telemetry-consent-design.md`](telemetry-consent-design.md). Local diagnostic audit records are a separate operator-selected sink and are not uploaded. +The privacy-reviewed version 3 `en-US` consent wording is the canonical +disclosure. Its title, body, action labels, and privacy link are documented in +[Telemetry consent design](telemetry-consent-design.md#canonical-consent-resource) +and must be rendered verbatim by every EXE and SDK presenter. + The optional ETW events contain MXC version/channel, debug-build state, caller-requested sandbox kind, selected backend, bounded outcomes and failure categories, numeric status values, lifecycle phase, policy fingerprints, and diff --git a/src/core/mxc-sdk/src/sandbox.rs b/src/core/mxc-sdk/src/sandbox.rs index ae6a377a88..1b350fdcbd 100644 --- a/src/core/mxc-sdk/src/sandbox.rs +++ b/src/core/mxc-sdk/src/sandbox.rs @@ -166,10 +166,10 @@ impl Sandbox { // Take both streams before waiting so `wait` won't discard them, and // read each on its own thread so the child never blocks on a full pipe. - let warnings = self.inner.warnings().to_vec(); let stdout = capture(self.inner.take_stdout()); let stderr = capture(self.inner.take_stderr()); let outcome = self.wait()?; + let warnings = self.inner.warnings().to_vec(); let output_metadata = self.inner.output_metadata().cloned(); Ok(Output { outcome, @@ -205,6 +205,7 @@ mod tests { struct FakeProcess { warnings: Vec, + wait_warning: Option, output_metadata: Option, } @@ -242,6 +243,9 @@ mod tests { } fn wait(&mut self) -> std::io::Result { + if let Some(warning) = self.wait_warning.take() { + self.warnings.push(warning); + } Ok(0) } } @@ -249,8 +253,10 @@ mod tests { #[test] fn sandbox_and_output_expose_security_warnings() { let warning = "permissive mode weakens containment".to_string(); + let wait_warning = "telemetry emission failed".to_string(); let sandbox = Sandbox::new(Box::new(FakeProcess { warnings: vec![warning.clone()], + wait_warning: Some(wait_warning.clone()), output_metadata: Some(SandboxOutputMetadata { capture_denials: Some(CaptureDenialsOutput { kind: CaptureDenialsOutput::KIND.to_string(), @@ -267,7 +273,7 @@ mod tests { assert_eq!(sandbox.warnings(), [warning.as_str()]); let output = sandbox.wait_with_output().expect("wait succeeds"); - assert_eq!(output.warnings, [warning]); + assert_eq!(output.warnings, [warning, wait_warning]); assert_eq!( output .output_metadata diff --git a/src/core/mxc_engine/src/lib.rs b/src/core/mxc_engine/src/lib.rs index 1bb81f5e36..982b06cae9 100644 --- a/src/core/mxc_engine/src/lib.rs +++ b/src/core/mxc_engine/src/lib.rs @@ -129,15 +129,15 @@ pub fn spawn(request: &SandboxRequest) -> Result, Error> let extra_warnings = logger.take_warnings(); let process: Box = ProcessWithWarnings::wrap(process, extra_warnings); if telemetry_registration.active() { - Ok(Box::new(TelemetryProcess { - inner: process, - active: telemetry_registration.transfer(), - mode: TelemetryMode::OneShot { + Ok(Box::new(TelemetryProcess::new( + process, + telemetry_registration.transfer(), + TelemetryMode::OneShot { containment, requested_sandbox_kind, }, started, - })) + ))) } else { Ok(process) } @@ -194,6 +194,7 @@ impl Drop for TelemetryRegistration { struct TelemetryProcess { inner: Box, active: bool, + warnings: Vec, mode: TelemetryMode, started: std::time::Instant, } @@ -221,39 +222,44 @@ pub(crate) fn wrap_state_aware_telemetry_process_with_kind( started: std::time::Instant, ) -> Box { if active { - Box::new(TelemetryProcess { - inner: process, - active: true, - mode: TelemetryMode::StateAware { + Box::new(TelemetryProcess::new( + process, + true, + TelemetryMode::StateAware { backend, phase, correlation_vector, requested_sandbox_kind, }, started, - }) + )) } else { process } } impl TelemetryProcess { - /// Emit the single terminal event for this invocation and release the - /// provider reference. Idempotent — subsequent calls (from another exit - /// path or `Drop`) are silent no-ops. - fn emit(&mut self, result: &std::io::Result) { - if !self.active { - return; + fn new( + inner: Box, + active: bool, + mode: TelemetryMode, + started: std::time::Instant, + ) -> Self { + let warnings = inner.warnings().to_vec(); + Self { + inner, + active, + warnings, + mode, + started, } - let output_metadata = result - .as_ref() - .ok() - .and_then(|_| self.inner.output_metadata().cloned()) - .map(Box::new); - let response = match result { + } + + fn completion_response(&self, result: &std::io::Result) -> ScriptResponse { + let output_metadata = self.inner.output_metadata().cloned().map(Box::new); + let mut response = match result { Ok(exit_code) => ScriptResponse { exit_code: *exit_code, - output_metadata, ..Default::default() }, Err(error) if error.kind() == std::io::ErrorKind::TimedOut => ScriptResponse { @@ -267,21 +273,45 @@ impl TelemetryProcess { ..Default::default() }, }; - match &self.mode { + response.output_metadata = output_metadata; + response + } + + fn report_verbose_telemetry_failure(&mut self, error: String) { + let warning = + format!("telemetry: captureDenials verbose artifact was not emitted: {error}"); + if !self.warnings.contains(&warning) { + self.warnings.push(warning); + } + } + + /// Emit the single terminal event for this invocation and release the + /// provider reference. Idempotent — subsequent calls (from another exit + /// path or `Drop`) are silent no-ops. + fn emit(&mut self, result: &std::io::Result) { + if !self.active { + return; + } + let response = self.completion_response(result); + let verbose_error = match &self.mode { TelemetryMode::OneShot { containment, requested_sandbox_kind, } => { #[cfg(target_os = "windows")] - let _ = - emit_verbose_telemetry(true, containment, *requested_sandbox_kind, &response); + let verbose_error = + emit_verbose_telemetry(true, containment, *requested_sandbox_kind, &response) + .err(); + #[cfg(not(target_os = "windows"))] + let verbose_error: Option = None; telemetry::emit_sdk_completion_with_kind( true, containment, *requested_sandbox_kind, &response, self.started.elapsed(), - ) + ); + verbose_error } TelemetryMode::StateAware { backend, @@ -312,7 +342,11 @@ impl TelemetryProcess { self.started.elapsed(), failure_reason, ); + None } + }; + if let Some(error) = verbose_error { + self.report_verbose_telemetry_failure(error); } self.active = false; } @@ -437,7 +471,7 @@ impl Drop for TelemetryProcess { impl SandboxProcess for TelemetryProcess { fn warnings(&self) -> &[String] { - self.inner.warnings() + &self.warnings } fn output_metadata(&self) -> Option<&wxc_common::models::SandboxOutputMetadata> { @@ -600,6 +634,7 @@ mod telemetry_process_tests { kill_fails: bool, finalized: Option>, metadata_read_before_finalization: Option>, + output_metadata: Option, } impl SandboxProcess for StubProcess { @@ -657,7 +692,7 @@ mod telemetry_process_tests { read_before_finalization.store(true, std::sync::atomic::Ordering::SeqCst); } } - None + self.output_metadata.as_ref() } } @@ -669,23 +704,24 @@ mod telemetry_process_tests { try_wait_result: TryWaitResult, kill_fails: bool, ) -> TelemetryProcess { - TelemetryProcess { - inner: Box::new(StubProcess { + TelemetryProcess::new( + Box::new(StubProcess { try_wait_result, wait_result: Ok(0), kill_fails, finalized: None, metadata_read_before_finalization: None, + output_metadata: None, }), - active: true, - mode: TelemetryMode::StateAware { + true, + TelemetryMode::StateAware { backend: "test".to_string(), phase: "exec".to_string(), correlation_vector: String::new(), requested_sandbox_kind: None, }, - started: std::time::Instant::now(), - } + std::time::Instant::now(), + ) } #[test] @@ -755,6 +791,7 @@ mod telemetry_process_tests { kill_fails: false, finalized: None, metadata_read_before_finalization: None, + output_metadata: None, }; assert_eq!(observe_before_drop(&mut exited), DropDisposition::Exited(7)); @@ -764,6 +801,7 @@ mod telemetry_process_tests { kill_fails: false, finalized: None, metadata_read_before_finalization: None, + output_metadata: None, }; assert_eq!( observe_before_drop(&mut running), @@ -776,6 +814,7 @@ mod telemetry_process_tests { kill_fails: false, finalized: None, metadata_read_before_finalization: None, + output_metadata: None, }; assert_eq!(observe_before_drop(&mut failed), DropDisposition::Abandoned); @@ -785,6 +824,7 @@ mod telemetry_process_tests { kill_fails: false, finalized: None, metadata_read_before_finalization: None, + output_metadata: None, }; assert_eq!( observe_before_drop(&mut timed_out), @@ -807,27 +847,79 @@ mod telemetry_process_tests { let finalized = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let metadata_read_before_finalization = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let mut process = TelemetryProcess { - inner: Box::new(StubProcess { + let mut process = TelemetryProcess::new( + Box::new(StubProcess { try_wait_result: TryWaitResult::Exited(7), wait_result: Ok(7), kill_fails: false, finalized: Some(finalized.clone()), metadata_read_before_finalization: Some(metadata_read_before_finalization.clone()), + output_metadata: None, }), - active: true, - mode: TelemetryMode::StateAware { + true, + TelemetryMode::StateAware { backend: "test".to_string(), phase: "exec".to_string(), correlation_vector: String::new(), requested_sandbox_kind: None, }, - started: std::time::Instant::now(), - }; + std::time::Instant::now(), + ); assert_eq!(process.try_wait().unwrap(), Some(7)); assert!(finalized.load(std::sync::atomic::Ordering::SeqCst)); assert!(!metadata_read_before_finalization.load(std::sync::atomic::Ordering::SeqCst)); assert!(!process.active); } + + #[cfg(target_os = "windows")] + #[test] + fn wait_error_preserves_finalized_metadata_and_reports_verbose_failure() { + use wxc_common::models::{CaptureDenialsOutput, SandboxOutputMetadata}; + + let metadata = SandboxOutputMetadata { + capture_denials: Some(CaptureDenialsOutput { + kind: CaptureDenialsOutput::KIND.to_string(), + output_path: "missing-denials.json".to_string(), + exit_code: 0, + total_denials: 0, + denied_resources_truncated: false, + etl_path: None, + }), + capture_denials_error: None, + }; + let mut process = TelemetryProcess::new( + Box::new(StubProcess { + try_wait_result: TryWaitResult::Running, + wait_result: Err(std::io::Error::other("retention failed")), + kill_fails: false, + finalized: Some(std::sync::Arc::new(std::sync::atomic::AtomicBool::new( + true, + ))), + metadata_read_before_finalization: Some(std::sync::Arc::new( + std::sync::atomic::AtomicBool::new(false), + )), + output_metadata: Some(metadata), + }), + true, + TelemetryMode::OneShot { + containment: ContainmentBackend::ProcessContainer, + requested_sandbox_kind: None, + }, + std::time::Instant::now(), + ); + + let response = process.completion_response(&Err(std::io::Error::other("retention failed"))); + assert!(response + .output_metadata + .as_deref() + .and_then(|metadata| metadata.capture_denials.as_ref()) + .is_some()); + + process.emit(&Err(std::io::Error::other("retention failed"))); + assert!(process + .warnings() + .iter() + .any(|warning| warning.contains("could not open the verbose artifact"))); + } } diff --git a/src/host/plm/readme.md b/src/host/plm/readme.md index e83d350629..6a0abf428b 100644 --- a/src/host/plm/readme.md +++ b/src/host/plm/readme.md @@ -11,7 +11,7 @@ PLM is invoked automatically by [`wxc-exec --audit`](../../../README.md#audit-mo 1. **Capture** — the public `plm.exe` runs `asInvoker`; it does not add a service. It uses UAC once to launch a retained restricted child. That child acquires the host-wide singleton, starts WPR with the embedded profile, and remains alive through explicit stop or uncertain teardown. 2. **Run** — the operator runs the workload. The OS-side permissive sandbox logs `EventID=14` / `EventID=27` for every access that *would* have been denied. 3. **Stop** — the retained child returns ETL bytes and status over the original authenticated named pipe. The unelevated parent owns the trace, log, and config destinations, then analyzes the ETL through `EtlDenialAnalyzer`. -4. **Emit** — actionable findings are written to `denials.json` in the log directory, and a one-line JSON result reports the trace, denials, and optional adjusted-config paths. +4. **Emit** — actionable findings are written to `denials.json` and bounded sanitized diagnostics to `denials.verbose.json` in the log directory. A one-line JSON result reports the trace, denials, and optional adjusted-config paths. 5. **Merge (temporary compatibility)** — file and capability denials are adapted into the existing adjusted-config generator until the shared regeneration engine replaces it. > **Capability merge caveats.** Capabilities are only merged into a `processContainer` block — backends that cannot express AppContainer capabilities (LXC, Windows Sandbox, …) are left untouched and the discovered set is reported on stderr instead. The reserved names `learningModeLogging` and `permissiveLearningMode` are never written back, because `processContainer.capabilities` rejects them. diff --git a/src/testing/wxc_e2e_tests/tests/e2e_telemetry_etw.rs b/src/testing/wxc_e2e_tests/tests/e2e_telemetry_etw.rs index a56c205100..c1ccdc6871 100644 --- a/src/testing/wxc_e2e_tests/tests/e2e_telemetry_etw.rs +++ b/src/testing/wxc_e2e_tests/tests/e2e_telemetry_etw.rs @@ -41,6 +41,8 @@ const FAKE_GROUP_GUID: &str = "4d584354-0000-4000-8000-e2e7e57fa4e0"; /// is the only handle an out-of-process collector has on the provider. const PROVIDER_GUID: &str = "{7f10def4-a258-5fea-510e-2c3bb976687f}"; +static ETW_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + /// Target directory for the instrumented build. Kept apart from `target/debug` /// so the group-joined binary can never be picked up by `find_binary` and so /// this build does not contend for the main target directory's lock. @@ -231,6 +233,59 @@ fn run_traced_execution(exe: &Path, local_app_data: &Path) -> std::process::Outp .expect("failed to run wxc-exec") } +fn write_capture_config(workdir: &Path) -> PathBuf { + let config_path = workdir.join("capture-config.json"); + let output_path = workdir.join("denials.json"); + let config = serde_json::json!({ + "version": "0.8.0-alpha", + "containerId": "TelemetryVerboseDenialsE2E", + "containment": "processcontainer", + "process": { + "commandLine": "cmd.exe /c type C:\\Windows\\System32\\config\\SAM >nul 2>&1 & exit /b 0" + }, + "processContainer": { + "captureDenials": { + "mode": "block", + "outputPath": output_path + } + }, + "telemetry": { + "enabled": true + } + }); + std::fs::write( + &config_path, + serde_json::to_vec_pretty(&config).expect("failed to serialize capture config"), + ) + .expect("failed to write capture config"); + config_path +} + +fn run_traced_config( + exe: &Path, + local_app_data: &Path, + config_path: &Path, +) -> std::process::Output { + Command::new(exe) + .arg("--config") + .arg(config_path) + .env("MXC_TEST_LOCALAPPDATA_OVERRIDE", local_app_data) + .output() + .expect("failed to run wxc-exec capture config") +} + +fn find_verbose_artifact(workdir: &Path) -> Option { + std::fs::read_dir(workdir) + .ok()? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .find(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("denials.") && name.ends_with(".verbose.json")) + }) +} + /// Decodes an `.etl` to XML and returns the text, or `None` if it holds no /// decodable events (`tracerpt` reports failure on an empty trace). fn decode_trace(etl: &Path, workdir: &Path) -> Option { @@ -274,9 +329,32 @@ fn assert_events_recorded(dump: &str) { } } +fn assert_verbose_event_recorded(dump: &str) { + assert!( + dump.contains("MXC.VerboseDenials"), + "no MXC.VerboseDenials event was recorded for a completed captureDenials run" + ); + for field in [ + "mxc.document_id", + "mxc.document_version", + "mxc.chunk_index", + "mxc.chunk_count", + "mxc.document_bytes", + "mxc.document_sha256", + "mxc.content", + "mxc.summary", + ] { + assert!( + dump.contains(field), + "recorded MXC.VerboseDenials event is missing the {field} field" + ); + } +} + #[test] #[ignore] // Builds a second wxc-exec.exe and needs rights to create an ETW session. fn test_telemetry_emits_etw_events_when_consent_granted() { + let _etw_test_guard = ETW_TEST_LOCK.lock().expect("ETW test lock was poisoned"); let exe = build_instrumented_wxc_exec(); let provider_def = generated_provider_def(); @@ -345,3 +423,69 @@ fn test_telemetry_emits_etw_events_when_consent_granted() { ); } } + +#[test] +#[ignore] // Builds a second wxc-exec.exe and needs captureDenials plus ETW privileges. +fn test_verbose_denials_etw_payload_honors_consent() { + let _etw_test_guard = ETW_TEST_LOCK.lock().expect("ETW test lock was poisoned"); + let exe = build_instrumented_wxc_exec(); + let workdir = build_target_dir().join("verbose-etw-e2e"); + let _ = std::fs::remove_dir_all(&workdir); + std::fs::create_dir_all(&workdir).expect("failed to create verbose ETW work directory"); + let config_path = write_capture_config(&workdir); + let session_name = format!("MxcVerboseTelemetryE2E_{}", std::process::id()); + + let granted_store = workdir.join("granted"); + seed_consent(&granted_store, "granted"); + assert_effective_consent(&exe, &granted_store, "granted"); + + let granted_etl = workdir.join("granted.etl"); + let Some(mut session) = EtwSession::start(&session_name, &granted_etl) else { + skip("could not create an ETW session for verbose telemetry"); + return; + }; + let granted_run = run_traced_config(&exe, &granted_store, &config_path); + session.stop(); + if !granted_run.status.success() { + skip(&format!( + "captureDenials is unavailable on this host: {}", + String::from_utf8_lossy(&granted_run.stderr) + )); + return; + } + + let verbose_path = find_verbose_artifact(&workdir) + .expect("captureDenials succeeded without publishing denials.verbose.json"); + let verbose: serde_json::Value = serde_json::from_slice( + &std::fs::read(&verbose_path).expect("failed to read verbose artifact"), + ) + .expect("verbose artifact was not valid JSON"); + assert_eq!(verbose["version"], 2); + + let granted_dump = decode_trace(&granted_etl, &workdir) + .expect("tracerpt produced no output for the verbose telemetry run"); + assert_verbose_event_recorded(&granted_dump); + + let denied_store = workdir.join("denied"); + seed_consent(&denied_store, "denied"); + assert_effective_consent(&exe, &denied_store, "denied"); + + let denied_etl = workdir.join("denied.etl"); + let Some(mut denied_session) = EtwSession::start(&session_name, &denied_etl) else { + skip("could not create the denied-consent ETW session"); + return; + }; + let denied_run = run_traced_config(&exe, &denied_store, &config_path); + denied_session.stop(); + assert!( + denied_run.status.success(), + "captureDenials failed under denied consent: {}", + String::from_utf8_lossy(&denied_run.stderr) + ); + if let Some(denied_dump) = decode_trace(&denied_etl, &workdir) { + assert!( + !denied_dump.contains("MXC.VerboseDenials"), + "verbose telemetry was emitted despite consent being denied" + ); + } +} From 5580c4fc8967785093ab9ea6750a7f69424e1991 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Tue, 1 Sep 2026 17:48:16 -0700 Subject: [PATCH 6/9] Redact verbose telemetry property values Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ba4f8739-616b-4b42-8eba-24a762602400 --- README.md | 9 +++--- docs/learning-mode/capabilities.md | 7 +++-- docs/telemetry/telemetry.md | 21 ++++++++------ src/core/mxc_engine/src/verbose_telemetry.rs | 29 ++++++++++++++++++-- 4 files changed, 48 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 7bcb23191b..021cc13927 100644 --- a/README.md +++ b/README.md @@ -266,10 +266,11 @@ No PII is collected. Execution/error events contain only execution metrics (`error_type`). When a ProcessContainer run successfully produces a Learning Mode verbose artifact, `MXC.VerboseDenials` can include sanitized provider/event identifiers, process IDs, closed outcome reasons, -access/resource classifications, bounded redacted properties, occurrence -counts, and truncation state. It excludes commands, credentials, complete file -paths, usernames, sandbox output, raw ETL, actionable denial documents, -general logger text, and free-form error text. The verbose-event data inventory +classifications, property names with every property value replaced by the +fixed `` marker, occurrence counts, and truncation state. It excludes +commands, credentials, complete file paths, usernames, workload-derived +property values, sandbox output, raw ETL, actionable denial documents, general +logger text, and free-form error text. The verbose-event data inventory requires explicit privacy/release review before shipment. If you use the SDK to build applications, you are responsible for providing appropriate telemetry notices to your own users. diff --git a/docs/learning-mode/capabilities.md b/docs/learning-mode/capabilities.md index 841de0a9fc..eede91eef3 100644 --- a/docs/learning-mode/capabilities.md +++ b/docs/learning-mode/capabilities.md @@ -334,8 +334,11 @@ above. 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. MXC does not send the actionable denials file or raw -ETL through telemetry. See [MXC telemetry](../telemetry/telemetry.md). +reconstruction metadata. Before emission, MXC replaces every verbose property +value with the fixed `` marker; property names remain to identify +provider schema compatibility patterns. MXC does not send the actionable +denials file, workload-derived property values, or raw ETL through telemetry. +See [MXC telemetry](../telemetry/telemetry.md). **Locating the file.** Set `captureDenials.outputPath` to name the file explicitly (its parent directory must already exist). MXC inserts a unique diff --git a/docs/telemetry/telemetry.md b/docs/telemetry/telemetry.md index 1eb949ccda..290e996715 100644 --- a/docs/telemetry/telemetry.md +++ b/docs/telemetry/telemetry.md @@ -198,9 +198,11 @@ 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`, and reserializes it as compact JSON. The event never -contains the actionable denials file, raw ETL, commands, sandbox output, or -general logger text. +`VerboseLoggingDocument`, replaces every workload-derived property value with +the fixed `` marker, and serializes that telemetry-specific +projection as compact JSON. Property names are retained to identify provider +schema compatibility patterns. 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 @@ -216,8 +218,8 @@ signature objects; MXC never splits a JSON object or UTF-8 code point. | `mxc.document_version` | uint32 | `VerboseLoggingDocument` schema version | | `mxc.chunk_index` | uint32 | Zero-based chunk index | | `mxc.chunk_count` | uint32 | Total number of chunks | -| `mxc.document_bytes` | uint64 | Canonical compact document byte count | -| `mxc.document_sha256` | string | SHA-256 of the canonical compact document | +| `mxc.document_bytes` | uint64 | Compact telemetry-projection byte count | +| `mxc.document_sha256` | string | SHA-256 of the compact telemetry projection | | `mxc.content` | string | Valid compact JSON array of complete verbose signatures | | `mxc.summary` | string | Valid compact JSON verbose-document summary | @@ -241,12 +243,13 @@ MXC's optional diagnostic events contain: - An MXC-internal random lifecycle correlation vector - Sanitized Learning Mode verbose signatures when `captureDenials` produces a verbose artifact: provider/event identifiers, PID, closed outcome reason, - access/resource classifications, bounded redacted properties, occurrence - counts, and truncation state + access/resource classifications, property names with every property value + replaced by the fixed `` marker, occurrence counts, and truncation + state MXC does not emit commands, credentials, complete file paths, usernames, -sandbox output, raw ETL, actionable denial documents, general logger text, or -free-form error text. +workload-derived property values, sandbox output, raw ETL, actionable denial +documents, general logger text, or free-form error text. ### Privacy review status diff --git a/src/core/mxc_engine/src/verbose_telemetry.rs b/src/core/mxc_engine/src/verbose_telemetry.rs index c7a8fe5c1b..f197907ac1 100644 --- a/src/core/mxc_engine/src/verbose_telemetry.rs +++ b/src/core/mxc_engine/src/verbose_telemetry.rs @@ -16,6 +16,9 @@ use wxc_common::telemetry::{self, VerboseEvent}; const MAX_CONTENT_BYTES: usize = 48 * 1024; /// The pretty-printed file is bounded separately from its compact form. const MAX_INPUT_BYTES: u64 = 32 * 1024 * 1024; +/// Property names describe provider schemas, but values can contain +/// workload-controlled object names and are never included in telemetry. +const REDACTED_PROPERTY_VALUE: &str = ""; #[derive(Debug)] struct PreparedVerboseDocument { @@ -121,6 +124,7 @@ fn prepare_document(path: &Path) -> Result { if document.version != VerboseLoggingDocument::VERSION { return Err("verbose artifact uses an unsupported document version".to_string()); } + let document = project_for_telemetry(document); let compact = serde_json::to_vec(&document) .map_err(|_| "could not compact the verbose artifact".to_string())?; @@ -142,6 +146,15 @@ fn prepare_document(path: &Path) -> Result { }) } +fn project_for_telemetry(mut document: VerboseLoggingDocument) -> VerboseLoggingDocument { + for aggregate in &mut document.signatures { + for (_, value) in &mut aggregate.signature.properties { + REDACTED_PROPERTY_VALUE.clone_into(value); + } + } + document +} + fn chunk_signatures(document: &VerboseLoggingDocument) -> Result, String> { let mut chunks = Vec::new(); let mut current = String::from("["); @@ -267,11 +280,14 @@ mod tests { } #[test] - fn prepared_chunks_reconstruct_the_canonical_document() { + fn prepared_chunks_reconstruct_the_telemetry_projection() { let directory = tempfile::tempdir().unwrap(); let denials_path = directory.path().join("denials.json"); let verbose_path = verbose_logging_sibling_path(&denials_path).unwrap(); - let doc = document(vec![aggregate(1, "one"), aggregate(2, "two")]); + let doc = document(vec![ + aggregate(1, "customer-secret-one"), + aggregate(2, "customer-secret-two"), + ]); let mut pretty = Vec::new(); learning_mode_core::write_verbose_logging_document(&mut pretty, &doc).unwrap(); std::fs::write(&verbose_path, pretty).unwrap(); @@ -309,7 +325,14 @@ mod tests { format!("{:x}", Sha256::digest(&compact)), prepared.document_sha256 ); - assert_eq!(reconstructed, doc); + assert_eq!(reconstructed, project_for_telemetry(doc)); + assert!(reconstructed.signatures.iter().all(|aggregate| { + aggregate + .signature + .properties + .iter() + .all(|(_, value)| value == REDACTED_PROPERTY_VALUE) + })); } #[test] From e3133fd8782407b360cd17d927d8af2bea35a309 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Tue, 1 Sep 2026 18:58:11 -0700 Subject: [PATCH 7/9] Close verbose telemetry projection Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ba4f8739-616b-4b42-8eba-24a762602400 --- README.md | 16 +++--- docs/learning-mode/capabilities.md | 9 ++-- docs/telemetry/telemetry.md | 18 +++---- src/core/mxc_engine/src/verbose_telemetry.rs | 52 +++++++++++++++----- 4 files changed, 60 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 021cc13927..19b9095785 100644 --- a/README.md +++ b/README.md @@ -266,14 +266,14 @@ No PII is collected. Execution/error events contain only execution metrics (`error_type`). When a ProcessContainer run successfully produces a Learning Mode verbose artifact, `MXC.VerboseDenials` can include sanitized provider/event identifiers, process IDs, closed outcome reasons, -classifications, property names with every property value replaced by the -fixed `` marker, occurrence counts, and truncation state. It excludes -commands, credentials, complete file paths, usernames, workload-derived -property values, sandbox output, raw ETL, actionable denial documents, general -logger text, and free-form error text. The verbose-event data inventory -requires explicit privacy/release review before shipment. If you use the SDK -to build applications, you are responsible for providing appropriate telemetry -notices to your own users. +access/resource classifications, occurrence counts, and truncation state. The +telemetry projection derives provider GUIDs from a closed provider enum and +drops all verbose property names and values. MXC never emits commands, +credentials, complete file paths, usernames, workload-derived properties, sandbox output, +raw ETL, actionable denial documents, general logger text, or free-form error +text. The verbose-event data inventory requires explicit privacy/release review +before shipment. If you use the SDK to build applications, you are responsible +for providing appropriate telemetry notices to your own users. Privacy information can be found at https://privacy.microsoft.com and in the Microsoft privacy statement at https://go.microsoft.com/fwlink/?LinkID=824704. diff --git a/docs/learning-mode/capabilities.md b/docs/learning-mode/capabilities.md index eede91eef3..2e30e1285f 100644 --- a/docs/learning-mode/capabilities.md +++ b/docs/learning-mode/capabilities.md @@ -334,11 +334,10 @@ above. 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 replaces every verbose property -value with the fixed `` marker; property names remain to identify -provider schema compatibility patterns. MXC does not send the actionable -denials file, workload-derived property values, or raw ETL through telemetry. -See [MXC telemetry](../telemetry/telemetry.md). +reconstruction metadata. Before emission, MXC derives provider GUIDs from the +closed 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](../telemetry/telemetry.md). **Locating the file.** Set `captureDenials.outputPath` to name the file explicitly (its parent directory must already exist). MXC inserts a unique diff --git a/docs/telemetry/telemetry.md b/docs/telemetry/telemetry.md index 290e996715..181ba5b2ae 100644 --- a/docs/telemetry/telemetry.md +++ b/docs/telemetry/telemetry.md @@ -198,11 +198,11 @@ 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`, replaces every workload-derived property value with -the fixed `` marker, and serializes that telemetry-specific -projection as compact JSON. Property names are retained to identify provider -schema compatibility patterns. The event never contains the actionable -denials file, raw ETL, commands, sandbox output, or general logger text. +`VerboseLoggingDocument`, derives each provider GUID from the document's +closed provider enum, drops every verbose property name and value, and +serializes that telemetry-specific projection as compact JSON. 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 @@ -243,12 +243,12 @@ MXC's optional diagnostic events contain: - An MXC-internal random lifecycle correlation vector - Sanitized Learning Mode verbose signatures when `captureDenials` produces a verbose artifact: provider/event identifiers, PID, closed outcome reason, - access/resource classifications, property names with every property value - replaced by the fixed `` marker, occurrence counts, and truncation - state + access/resource classifications, occurrence counts, and truncation state. + Provider GUIDs are derived from a closed provider enum; verbose property + names and values are dropped. MXC does not emit commands, credentials, complete file paths, usernames, -workload-derived property values, sandbox output, raw ETL, actionable denial +workload-derived properties, sandbox output, raw ETL, actionable denial documents, general logger text, or free-form error text. ### Privacy review status diff --git a/src/core/mxc_engine/src/verbose_telemetry.rs b/src/core/mxc_engine/src/verbose_telemetry.rs index f197907ac1..6e92755cb5 100644 --- a/src/core/mxc_engine/src/verbose_telemetry.rs +++ b/src/core/mxc_engine/src/verbose_telemetry.rs @@ -6,7 +6,9 @@ use std::io::Read; use std::path::Path; -use learning_mode_core::{verbose_logging_sibling_path, VerboseLoggingDocument}; +use learning_mode_core::{ + verbose_logging_sibling_path, VerboseLoggingDocument, VerboseLoggingProvider, +}; use sha2::{Digest, Sha256}; use wxc_common::models::{ContainmentBackend, ScriptResponse}; use wxc_common::telemetry::{self, VerboseEvent}; @@ -16,10 +18,6 @@ use wxc_common::telemetry::{self, VerboseEvent}; const MAX_CONTENT_BYTES: usize = 48 * 1024; /// The pretty-printed file is bounded separately from its compact form. const MAX_INPUT_BYTES: u64 = 32 * 1024 * 1024; -/// Property names describe provider schemas, but values can contain -/// workload-controlled object names and are never included in telemetry. -const REDACTED_PROPERTY_VALUE: &str = ""; - #[derive(Debug)] struct PreparedVerboseDocument { document_id: String, @@ -148,13 +146,22 @@ fn prepare_document(path: &Path) -> Result { fn project_for_telemetry(mut document: VerboseLoggingDocument) -> VerboseLoggingDocument { for aggregate in &mut document.signatures { - for (_, value) in &mut aggregate.signature.properties { - REDACTED_PROPERTY_VALUE.clone_into(value); - } + aggregate.signature.provider_guid = + canonical_provider_guid(aggregate.signature.provider).to_string(); + aggregate.signature.properties.clear(); } document } +fn canonical_provider_guid(provider: VerboseLoggingProvider) -> &'static str { + match provider { + VerboseLoggingProvider::KernelGeneral => "{A68CA8B7-004F-D7B6-A698-07E2DE0F1F5D}", + VerboseLoggingProvider::PrivacyAuditingPermissiveLearningMode => { + "{811A1DDB-2E69-5F25-ADC0-4B186170E760}" + } + } +} + fn chunk_signatures(document: &VerboseLoggingDocument) -> Result, String> { let mut chunks = Vec::new(); let mut current = String::from("["); @@ -326,12 +333,13 @@ mod tests { prepared.document_sha256 ); assert_eq!(reconstructed, project_for_telemetry(doc)); + assert!(reconstructed + .signatures + .iter() + .all(|aggregate| aggregate.signature.properties.is_empty())); assert!(reconstructed.signatures.iter().all(|aggregate| { - aggregate - .signature - .properties - .iter() - .all(|(_, value)| value == REDACTED_PROPERTY_VALUE) + aggregate.signature.provider_guid + == canonical_provider_guid(aggregate.signature.provider) })); } @@ -344,4 +352,22 @@ mod tests { assert_eq!(error, "verbose artifact is not valid typed JSON"); assert!(!error.contains("do-not-send")); } + + #[test] + fn telemetry_projection_drops_injected_strings() { + let injected = "customer-secret"; + let mut doc = document(vec![aggregate(1, injected)]); + doc.signatures[0].signature.provider_guid = injected.to_string(); + doc.signatures[0].signature.properties = vec![(injected.to_string(), injected.to_string())]; + + let projected = project_for_telemetry(doc); + let serialized = serde_json::to_string(&projected).unwrap(); + + assert!(!serialized.contains(injected)); + assert_eq!( + projected.signatures[0].signature.provider_guid, + canonical_provider_guid(VerboseLoggingProvider::KernelGeneral) + ); + assert!(projected.signatures[0].signature.properties.is_empty()); + } } From 121ae2eb468b260008eb6c68d3cdc0d1c22f87d3 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Wed, 9 Sep 2026 11:13:40 -0700 Subject: [PATCH 8/9] Fix verbose telemetry polling and E2E coverage Preserve the nonblocking try_wait contract and ensure the verbose ETW test uses the current schema and effective consent override. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e408b60b-e267-416c-806d-0a7e119fe53f --- docs/telemetry/telemetry.md | 3 ++ src/core/mxc_engine/src/lib.rs | 52 ++++++++++++++----- .../wxc_e2e_tests/tests/e2e_telemetry_etw.rs | 24 +++++++-- 3 files changed, 60 insertions(+), 19 deletions(-) diff --git a/docs/telemetry/telemetry.md b/docs/telemetry/telemetry.md index 181ba5b2ae..b9d0262cde 100644 --- a/docs/telemetry/telemetry.md +++ b/docs/telemetry/telemetry.md @@ -207,6 +207,9 @@ 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 signature objects; MXC never splits a JSON object or UTF-8 code point. +Streaming callers must use `wait` to finalize capture metadata. A terminal +result observed only through nonblocking `try_wait` emits completion telemetry +but not the optional verbose artifact event. | Field | Type | Description | |-------|------|-------------| diff --git a/src/core/mxc_engine/src/lib.rs b/src/core/mxc_engine/src/lib.rs index 982b06cae9..afcafed4bd 100644 --- a/src/core/mxc_engine/src/lib.rs +++ b/src/core/mxc_engine/src/lib.rs @@ -255,8 +255,14 @@ impl TelemetryProcess { } } - fn completion_response(&self, result: &std::io::Result) -> ScriptResponse { - let output_metadata = self.inner.output_metadata().cloned().map(Box::new); + fn completion_response( + &self, + result: &std::io::Result, + include_output_metadata: bool, + ) -> ScriptResponse { + let output_metadata = include_output_metadata + .then(|| self.inner.output_metadata().cloned().map(Box::new)) + .flatten(); let mut response = match result { Ok(exit_code) => ScriptResponse { exit_code: *exit_code, @@ -289,19 +295,37 @@ impl TelemetryProcess { /// provider reference. Idempotent — subsequent calls (from another exit /// path or `Drop`) are silent no-ops. fn emit(&mut self, result: &std::io::Result) { + self.emit_inner(result, true); + } + + /// Emit completion after a nonblocking terminal poll without forcing + /// backend teardown to finalize capture metadata. + fn emit_after_poll(&mut self, result: &std::io::Result) { + self.emit_inner(result, false); + } + + fn emit_inner(&mut self, result: &std::io::Result, include_verbose: bool) { if !self.active { return; } - let response = self.completion_response(result); + let response = self.completion_response(result, include_verbose); let verbose_error = match &self.mode { TelemetryMode::OneShot { containment, requested_sandbox_kind, } => { #[cfg(target_os = "windows")] - let verbose_error = - emit_verbose_telemetry(true, containment, *requested_sandbox_kind, &response) - .err(); + let verbose_error = include_verbose + .then(|| { + emit_verbose_telemetry( + true, + containment, + *requested_sandbox_kind, + &response, + ) + .err() + }) + .flatten(); #[cfg(not(target_os = "windows"))] let verbose_error: Option = None; telemetry::emit_sdk_completion_with_kind( @@ -493,12 +517,11 @@ impl SandboxProcess for TelemetryProcess { fn try_wait(&mut self) -> std::io::Result> { let result = self.inner.try_wait(); match &result { - // Finalize backend-owned output metadata after observing exit, - // while preserving the nonblocking poll's terminal result. + // Preserve the nonblocking contract. Capture metadata is finalized + // only by backend teardown, so this path emits completion without + // attempting the optional verbose artifact. Ok(Some(exit_code)) => { - let exit_code = *exit_code; - let _ = self.inner.wait(); - self.emit(&Ok(exit_code)); + self.emit_after_poll(&Ok(*exit_code)); } // Still running: leave the invariant to a later `wait` / `kill` / `Drop`. Ok(None) => {} @@ -843,7 +866,7 @@ mod telemetry_process_tests { } #[test] - fn terminal_poll_finalizes_before_reading_output_metadata() { + fn terminal_poll_does_not_wait_or_read_output_metadata() { let finalized = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let metadata_read_before_finalization = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); @@ -867,7 +890,7 @@ mod telemetry_process_tests { ); assert_eq!(process.try_wait().unwrap(), Some(7)); - assert!(finalized.load(std::sync::atomic::Ordering::SeqCst)); + assert!(!finalized.load(std::sync::atomic::Ordering::SeqCst)); assert!(!metadata_read_before_finalization.load(std::sync::atomic::Ordering::SeqCst)); assert!(!process.active); } @@ -909,7 +932,8 @@ mod telemetry_process_tests { std::time::Instant::now(), ); - let response = process.completion_response(&Err(std::io::Error::other("retention failed"))); + let response = + process.completion_response(&Err(std::io::Error::other("retention failed")), true); assert!(response .output_metadata .as_deref() diff --git a/src/testing/wxc_e2e_tests/tests/e2e_telemetry_etw.rs b/src/testing/wxc_e2e_tests/tests/e2e_telemetry_etw.rs index c1ccdc6871..c9d0f0f578 100644 --- a/src/testing/wxc_e2e_tests/tests/e2e_telemetry_etw.rs +++ b/src/testing/wxc_e2e_tests/tests/e2e_telemetry_etw.rs @@ -237,7 +237,7 @@ fn write_capture_config(workdir: &Path) -> PathBuf { let config_path = workdir.join("capture-config.json"); let output_path = workdir.join("denials.json"); let config = serde_json::json!({ - "version": "0.8.0-alpha", + "version": "0.9.0-alpha", "containerId": "TelemetryVerboseDenialsE2E", "containment": "processcontainer", "process": { @@ -270,10 +270,18 @@ fn run_traced_config( .arg("--config") .arg(config_path) .env("MXC_TEST_LOCALAPPDATA_OVERRIDE", local_app_data) + .env( + "MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID", + std::process::id().to_string(), + ) .output() .expect("failed to run wxc-exec capture config") } +fn capture_denials_is_unavailable(stderr: &[u8]) -> bool { + String::from_utf8_lossy(stderr).contains("captureDenials requires") +} + fn find_verbose_artifact(workdir: &Path) -> Option { std::fs::read_dir(workdir) .ok()? @@ -447,11 +455,17 @@ fn test_verbose_denials_etw_payload_honors_consent() { let granted_run = run_traced_config(&exe, &granted_store, &config_path); session.stop(); if !granted_run.status.success() { - skip(&format!( - "captureDenials is unavailable on this host: {}", + if capture_denials_is_unavailable(&granted_run.stderr) { + skip(&format!( + "captureDenials is unavailable on this host: {}", + String::from_utf8_lossy(&granted_run.stderr) + )); + return; + } + panic!( + "verbose telemetry execution failed: {}", String::from_utf8_lossy(&granted_run.stderr) - )); - return; + ); } let verbose_path = find_verbose_artifact(&workdir) From be43d86961bcd011268a823c76d822bd5ed05465 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Wed, 9 Sep 2026 14:49:10 -0700 Subject: [PATCH 9/9] Trigger pull request validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e408b60b-e267-416c-806d-0a7e119fe53f