diff --git a/README.md b/README.md index 566cd3dec..19b909578 100644 --- a/README.md +++ b/README.md @@ -225,13 +225,13 @@ 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. ## 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.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,21 @@ 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. - -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. +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 sanitized +provider/event identifiers, process IDs, closed outcome reasons, +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 98a6879f5..2e30e1285 100644 --- a/docs/learning-mode/capabilities.md +++ b/docs/learning-mode/capabilities.md @@ -331,6 +331,14 @@ 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.VerboseDenials`. Each +event contains a valid JSON array of complete signatures and document +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 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 084823e89..55529e05d 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: `1` +- 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. +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. ``` ### Actions @@ -195,6 +199,7 @@ The public ETW event identities remain: - `MXC.Execution` - `MXC.Error` +- `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 7db7618ff..b9d0262cd 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.VerboseDenials`. `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,76 @@ Emitted on execution errors. > attribution fields, the `error_type` category, and the numeric `exit_code` — > never the message string itself. +### MXC.VerboseDenials + +Emitted when a telemetry-enabled ProcessContainer run successfully produces a +Learning Mode `captureDenials` verbose logging artifact. MXC reads the +versioned `*.verbose.json` sibling, validates it as a +`VerboseLoggingDocument`, derives each provider GUID from the document's +closed provider enum, drops every verbose property name and value, and +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 +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 | +|-------|------|-------------| +| `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 | 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 | + +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, 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 properties, sandbox output, raw ETL, actionable denial +documents, general logger text, or free-form error text. + +### Privacy review status + +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 The state-aware lifecycle runs each phase (`provision` → `start` → `exec` → @@ -314,6 +393,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/sdk/dotnet/README.md b/sdk/dotnet/README.md index 7849735f6..0d78e37ac 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 d01f9cae9..257bf4bd3 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 104f07376..018922434 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-sdk/src/sandbox.rs b/src/core/mxc-sdk/src/sandbox.rs index ae6a377a8..1b350fdcb 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/Cargo.toml b/src/core/mxc_engine/Cargo.toml index 3b76341af..7813e523a 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 b7d09acb3..afcafed4b 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}; @@ -125,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) } @@ -190,6 +194,7 @@ impl Drop for TelemetryRegistration { struct TelemetryProcess { inner: Box, active: bool, + warnings: Vec, mode: TelemetryMode, started: std::time::Instant, } @@ -217,31 +222,48 @@ 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 response = match result { + } + + 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, ..Default::default() @@ -257,17 +279,64 @@ 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) { + 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, include_verbose); + let verbose_error = match &self.mode { 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 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( + true, + containment, + *requested_sandbox_kind, + &response, + self.started.elapsed(), + ); + verbose_error + } TelemetryMode::StateAware { backend, phase, @@ -297,7 +366,11 @@ impl TelemetryProcess { self.started.elapsed(), failure_reason, ); + None } + }; + if let Some(error) = verbose_error { + self.report_verbose_telemetry_failure(error); } self.active = false; } @@ -405,7 +478,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", @@ -417,7 +495,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> { @@ -439,8 +517,12 @@ 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)), + // 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)) => { + self.emit_after_poll(&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 +655,9 @@ mod telemetry_process_tests { try_wait_result: TryWaitResult, wait_result: std::io::Result, kill_fails: bool, + finalized: Option>, + metadata_read_before_finalization: Option>, + output_metadata: Option, } impl SandboxProcess for StubProcess { @@ -613,11 +698,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); + } + } + self.output_metadata.as_ref() + } } fn wrapped(try_wait_result: TryWaitResult) -> TelemetryProcess { @@ -628,21 +727,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] @@ -710,6 +812,9 @@ mod telemetry_process_tests { try_wait_result: TryWaitResult::Exited(7), wait_result: Ok(0), kill_fails: false, + finalized: None, + metadata_read_before_finalization: None, + output_metadata: None, }; assert_eq!(observe_before_drop(&mut exited), DropDisposition::Exited(7)); @@ -717,6 +822,9 @@ mod telemetry_process_tests { try_wait_result: TryWaitResult::Running, wait_result: Ok(0), kill_fails: false, + finalized: None, + metadata_read_before_finalization: None, + output_metadata: None, }; assert_eq!( observe_before_drop(&mut running), @@ -727,6 +835,9 @@ mod telemetry_process_tests { try_wait_result: TryWaitResult::Failed, wait_result: Ok(0), kill_fails: false, + finalized: None, + metadata_read_before_finalization: None, + output_metadata: None, }; assert_eq!(observe_before_drop(&mut failed), DropDisposition::Abandoned); @@ -734,6 +845,9 @@ mod telemetry_process_tests { try_wait_result: TryWaitResult::TimedOut, wait_result: Ok(0), kill_fails: false, + finalized: None, + metadata_read_before_finalization: None, + output_metadata: None, }; assert_eq!( observe_before_drop(&mut timed_out), @@ -750,4 +864,86 @@ mod telemetry_process_tests { assert_eq!(process.wait().unwrap(), 0); assert!(!process.active); } + + #[test] + 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)); + 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, + }), + true, + TelemetryMode::StateAware { + backend: "test".to_string(), + phase: "exec".to_string(), + correlation_vector: String::new(), + requested_sandbox_kind: None, + }, + 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")), true); + 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/core/mxc_engine/src/verbose_telemetry.rs b/src/core/mxc_engine/src/verbose_telemetry.rs new file mode 100644 index 000000000..6e92755cb --- /dev/null +++ b/src/core/mxc_engine/src/verbose_telemetry.rs @@ -0,0 +1,373 @@ +// 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, VerboseLoggingProvider, +}; +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())?; + match 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, + }, + ) { + Ok(true) => {} + Ok(false) => break, + Err(status) => { + return Err(format!( + "ETW rejected a verbose telemetry chunk with status {status}" + )); + } + } + } + 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 document = project_for_telemetry(document); + + 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 project_for_telemetry(mut document: VerboseLoggingDocument) -> VerboseLoggingDocument { + for aggregate in &mut document.signatures { + 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("["); + 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_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, "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(); + + 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, project_for_telemetry(doc)); + assert!(reconstructed + .signatures + .iter() + .all(|aggregate| aggregate.signature.properties.is_empty())); + assert!(reconstructed.signatures.iter().all(|aggregate| { + aggregate.signature.provider_guid + == canonical_provider_guid(aggregate.signature.provider) + })); + } + + #[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")); + } + + #[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()); + } +} diff --git a/src/core/wxc/src/main.rs b/src/core/wxc/src/main.rs index c5da7d58e..572f38ea2 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 228efa0c7..f8c20e856 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": 1, + "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. 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 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.rs b/src/core/wxc_common/src/telemetry/consent.rs index 4e15ec465..f50c70741 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 84597e5a0..c0db3c805 100644 --- a/src/core/wxc_common/src/telemetry/consent_prompt.rs +++ b/src/core/wxc_common/src/telemetry/consent_prompt.rs @@ -66,17 +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. MXC does not send your commands, file paths, credentials, or \ - other customer content.\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/core/wxc_common/src/telemetry/events.rs b/src/core/wxc_common/src/telemetry/events.rs index 966a3ca1d..52901f75f 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,29 @@ pub fn log_error( test_sink::record_error(ctx, sandbox_kind, error_type, exit_code); } +/// Log one `MXC.VerboseDenials` ETW event. +pub fn log_verbose(event: &VerboseEvent<'_>) -> u32 { + let status = 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); + + status +} + /// 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 +353,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 +390,23 @@ pub(super) mod test_sink { pub correlation_vector: String, } + /// Owned copy of an `MXC.VerboseDenials` 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 +424,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 +439,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 +456,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.VerboseDenials` 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 +557,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 +598,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 1d907a170..74a83d35e 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,20 @@ 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<'_>) -> Result { + let Some(_auth) = EmissionAuthorization::for_invocation(active) else { + return Ok(false); + }; + match log_verbose(event) { + 0 => Ok(true), + status => Err(status), + } +} + /// 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 +1297,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).unwrap()); + assert!(events::test_sink::take_verbose().is_empty()); + + TEST_AUTHORIZATION_OVERRIDE.with(|allowed| allowed.set(Some(true))); + assert!(emit_verbose(true, &event).unwrap()); + 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/host/plm/readme.md b/src/host/plm/readme.md index e83d35062..6a0abf428 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/mxc_telemetry/src/lib.rs b/src/mxc_telemetry/src/lib.rs index d74ec5825..beaa99a45 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, + ) -> u32 { + let state = match STATE.get() { + Some(s) => s, + None => return 0, + }; + + let is_debug_build = cfg!(debug_assertions); + + tracelogging::write_event!( + MXC_PROVIDER, + "MXC.VerboseDenials", + 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,23 @@ 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, + ) -> 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) {} pub fn log_process_kill_failed( @@ -626,6 +700,10 @@ mod tests { provider_source.contains("\"MXC.Error\","), "error event identity changed" ); + assert!( + provider_source.contains("\"MXC.VerboseDenials\","), + "verbose event identity changed" + ); let error_section = provider_source .split("\"MXC.Error\",") .nth(1) @@ -655,28 +733,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 +839,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 +874,7 @@ mod tests { "", ); log_error("test_backend", "test_backend", "unknown", 1, "", ""); + log_verbose("", "", "", "", "", 0, 0, 0, 0, "", "[]", "{}"); } #[test] @@ -808,6 +908,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 74ddee517..c9d0f0f57 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. @@ -190,7 +192,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":3, "promptLocale":"en-US","updatedAtEpoch":0}}"# ); std::fs::write(dir.join("telemetry-consent.json"), record.replace('\n', "")) @@ -231,6 +233,67 @@ 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.9.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) + .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()? + .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 +337,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 +431,75 @@ 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() { + 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) + ); + } + + 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" + ); + } +} diff --git a/tests/scripts/run_telemetry_consent_smoke_test.ps1 b/tests/scripts/run_telemetry_consent_smoke_test.ps1 index f4a837471..103a4c15e 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 = 1 + 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 5fe6dcf26..4eb1b93d5 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 = 1 + promptResourceVersion = 3 promptLocale = 'en-US' updatedAtEpoch = 0 }