From bbbcf341cb77d639555f47be7978ca6ea50a0401 Mon Sep 17 00:00:00 2001 From: "Ramon Arjona (from Dev Box)" Date: Wed, 29 Jul 2026 11:50:38 -0700 Subject: [PATCH 1/5] Add opt-in telemetry consent with an administrative policy ceiling Telemetry is Windows-only and is never collected without explicit user consent. MXC keeps its own consent state and never reads or infers from the Windows system telemetry consent. Three conditions must all hold before anything is emitted, combined by a single conjunction in wxc_common::telemetry::is_enabled: the user granted consent, the administrative policy permits it, and the config kill-switch is unset. Every error, unreadable value or ambiguity fails closed. - Consent: a per-user JSON store under %LOCALAPPDATA%, prompted on first run and revocable at any time. Non-Windows platforms report not-applicable and offer no consent surface. - Policy: HKLM\SOFTWARE\Policies\Mxc\AllowTelemetry (REG_DWORD), settable by Intune, other MDMs or Group Policy. It is a deny-only ceiling: it can restrict what a user permitted but can never opt a user in. Deliberately not under Policies\Microsoft, which Windows forbids ADMX-ingested policies from writing. - One definition in Rust, surfaced identically through wxc-exec, the Rust SDK, the C ABI, the C# SDK and the Node SDK. Read-only queries never throw and never crash the host; swallowed failures are reported once per process rather than silently hidden. Adds scripts/check-telemetry-policy-parity.js and extends the bindings-codegen check to cover the new FFI exports. Neither is wired into a workflow here: CI changes are build-owned and are proposed separately. Telemetry remains an experimental feature, so collection additionally requires --experimental and an experimental.telemetry block. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 043f96f9-03fd-4375-9528-23ee94d7c06c --- .github/copilot-instructions.md | 18 +- docs/telemetry/telemetry-consent-design.md | 655 ++++++++++++++ docs/telemetry/telemetry-policy.md | 232 +++++ docs/telemetry/telemetry.md | 57 ++ schemas/dev/mxc-config.schema.0.8.0-dev.json | 2 +- scripts/check-dotnet-bindings-codegen.js | 4 + scripts/check-telemetry-policy-parity.js | 150 ++++ .../MxcTelemetryTests.cs | 453 ++++++++++ sdk/dotnet/Microsoft.Mxc.Sdk/ErrorCode.cs | 8 + .../Microsoft.Mxc.Sdk.csproj | 23 +- sdk/dotnet/Microsoft.Mxc.Sdk/MxcException.cs | 14 +- sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs | 398 +++++++++ .../Native/NativeLibraryResolver.cs | 56 +- .../TelemetryConsentState.cs | 36 + .../Microsoft.Mxc.Sdk/TelemetryPolicyState.cs | 53 ++ sdk/dotnet/README.md | 72 +- sdk/node/README.md | 91 ++ sdk/node/package.json | 2 +- sdk/node/src/generated/wire.ts | 2 +- sdk/node/src/index.ts | 13 + sdk/node/src/telemetry.ts | 375 ++++++++ sdk/node/tests/unit/telemetry.test.ts | 400 +++++++++ src/Cargo.lock | 1 + src/core/lxc/src/main.rs | 63 +- src/core/mxc-sdk/README.md | 66 ++ src/core/mxc-sdk/src/lib.rs | 2 + src/core/mxc-sdk/src/telemetry.rs | 283 ++++++ src/core/mxc_darwin/src/main.rs | 54 ++ src/core/wxc/src/main.rs | 66 ++ src/core/wxc_common/Cargo.toml | 6 + src/core/wxc_common/src/models.rs | 5 +- src/core/wxc_common/src/telemetry/consent.rs | 835 ++++++++++++++++++ .../wxc_common/src/telemetry/consent_cli.rs | 427 +++++++++ src/core/wxc_common/src/telemetry/mod.rs | 214 ++++- src/core/wxc_common/src/telemetry/policy.rs | 495 +++++++++++ src/core/wxc_common/src/wire.rs | 6 +- src/ffi/mxc_ffi/Cargo.toml | 7 + src/ffi/mxc_ffi/src/lib.rs | 526 ++++++++++- .../run_telemetry_consent_smoke_test.ps1 | 202 +++++ 39 files changed, 6341 insertions(+), 31 deletions(-) create mode 100644 docs/telemetry/telemetry-consent-design.md create mode 100644 docs/telemetry/telemetry-policy.md create mode 100644 scripts/check-telemetry-policy-parity.js create mode 100644 sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs create mode 100644 sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs create mode 100644 sdk/dotnet/Microsoft.Mxc.Sdk/TelemetryConsentState.cs create mode 100644 sdk/dotnet/Microsoft.Mxc.Sdk/TelemetryPolicyState.cs create mode 100644 sdk/node/src/telemetry.ts create mode 100644 sdk/node/tests/unit/telemetry.test.ts create mode 100644 src/core/mxc-sdk/src/telemetry.rs create mode 100644 src/core/wxc_common/src/telemetry/consent.rs create mode 100644 src/core/wxc_common/src/telemetry/consent_cli.rs create mode 100644 src/core/wxc_common/src/telemetry/policy.rs create mode 100644 tests/scripts/run_telemetry_consent_smoke_test.ps1 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e2b9683ec..c02577a50 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -81,7 +81,8 @@ npm test npm run test:integration # C# SDK (from sdk/dotnet/) -dotnet test Microsoft.Mxc.Sdk.slnx # requires mxc_ffi built (cargo build -p mxc_ffi); resolver finds it in src/target/{debug,release} +dotnet test Microsoft.Mxc.Sdk.slnx # Debug only; requires mxc_ffi built (cargo build -p mxc_ffi); resolver finds it in src/target/{debug,release} + # the telemetry tests need the debug-only MXC_TEST_LOCALAPPDATA_OVERRIDE, so `-c Release` fails by design # Local PowerShell helpers — run from repo root, require built binaries tests\scripts\run_test_configs.ps1 # All test configs via wxc_test_driver @@ -92,6 +93,7 @@ tests\scripts\run_windows_sandbox_one_shot_tests.ps1 # Windows Sandbox one tests\scripts\run_windows_sandbox_state_aware_tests.ps1 # Windows Sandbox state-aware lifecycle E2E (provision/start/exec*/stop/deprovision; requires the Windows Sandbox optional feature; skips if absent) tests\scripts\run_lxc_all_tests.sh # All LXC tests (Linux) tests\scripts\run_bwrap_all_tests.sh # All Bubblewrap tests (Linux, requires bwrap) +tests\scripts\run_telemetry_consent_smoke_test.ps1 # Telemetry consent + policy CLI E2E (Windows; debug binary only) # E2E test crate — Rust executor integration tests (from src/) cargo test -p wxc_e2e_tests # Invokes MXC binaries directly @@ -153,6 +155,7 @@ Core references: - `docs/diagnostics.md` — diagnostic logging knobs (env vars, log file format) - `docs/host-prep.md` — `wxc-host-prep.exe` host setup binary (`prepare-system-drive` / `unprepare-system-drive` for the AppContainer ACEs on the system-drive root, plus `prepare-null-device` / `verify-null-device` / `dump-null-device` for the `\Device\Null` security descriptor that AppContainer-based backends require). Owns elevation via embedded `requireAdministrator` manifest — `wxc-exec.exe` no longer self-elevates. - `docs/sandbox-policy/v1/policy.md` — sandbox policy v1 specification +- `docs/telemetry/telemetry.md` — telemetry overview; `docs/telemetry/telemetry-consent-design.md` (Windows-only consent design and per-SDK surface) and `docs/telemetry/telemetry-policy.md` (the MDM / Group Policy ceiling) Per-backend guides: @@ -228,6 +231,18 @@ The parser deserializes JSON directly into the typed wire model (`wxc_common::wi - macOS: `mxc-exec-mac` (Seatbelt) - Target triples: `x86_64-pc-windows-msvc`, `aarch64-pc-windows-msvc`, `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`, `aarch64-apple-darwin` +### Telemetry consent + +Telemetry is **Windows-only** and is never collected without explicit user consent. Three independent conditions must all hold before anything is emitted: the user has granted consent, the administrative (MDM / Group Policy) ceiling permits it, and the config kill-switch has not disabled it. `wxc_common::telemetry::is_enabled()` is the single place those three terms are combined — do not re-derive enablement anywhere else. + +- **Everything fails closed.** Any error, unreadable value, corrupt file, missing native library, or ambiguity must resolve to "no telemetry" (`Undetermined` consent / `Blocked` policy), never to a permissive state. +- **Policy may restrict, but may never substitute for, consent.** The administrative policy is a deny-only ceiling: it can subtract from what a user permitted, never add to it. An administrator cannot opt a user in — a denied or never-asked user stays opted out even under `AllowTelemetry=3`. Keep the terms combined with `&&`; never add a policy value or config path that grants collection on its own. +- **MXC owns its own consent state.** It must never read or infer from the Windows system telemetry consent. The consent store is a per-user JSON file; the policy is `HKLM\SOFTWARE\Policies\Mxc` → `AllowTelemetry` (`REG_DWORD`). +- **One definition, distributed to the bindings.** The Rust `ConsentState` / `PolicyState` enums are the source of truth; the FFI, C#, and TypeScript layers marshal the same strings. `scripts/check-telemetry-policy-parity.js` fails if the four `PolicyState` spellings drift apart — it is currently an **on-demand check, not yet wired into any workflow**, so run it by hand after touching any of the three. +- **Test isolation.** The consent store and the policy key are process-global, each behind its own mutex. Use `wxc_common::telemetry::test_support::TelemetryTestEnv` whenever a test needs both; constructing `PolicyKeyGuard` and `LocalAppDataGuard` directly in the same test risks a lock-order deadlock. Both overrides are `cfg(debug_assertions)`-gated, so the smoke test refuses to run against a release binary. The `wxc_common` `test-support` feature re-exports the policy override for downstream crates' integration tests (`mxc_ffi` uses it) and must stay a dev-dependency-only feature. +- **Read-only queries must never be able to crash the host.** `NeedsConsentPrompt`/`needsTelemetryConsentPrompt` and `GetPolicy`/`getTelemetryPolicy` fail closed on *any* failure and never throw — including a non-`Success` FFI status, which covers a caught panic. The consent *read* and *write* still throw, because their callers must distinguish "not decided" from "could not read" and "did not persist"; when they do, they raise only the binding's documented exception type (`MxcException`), wrapping anything unexpected rather than letting a raw type escape. +- **Never swallow a failure silently.** Fail-closed return values are indistinguishable from legitimate ones, so a broken install would otherwise be invisible. Every swallowed failure is reported once per distinct failure per process (deduplicated — hosts poll these getters), and the reporter itself must never throw. At the FFI boundary, `catch_unwind` sites log the panic payload before returning `MXC_STATUS_PANIC`, which would otherwise be discarded. + ### Package versioning All Rust crates use `version.workspace = true` to inherit the version from `src/Cargo.toml` `[workspace.package]`. The npm SDK version in `sdk/node/package.json` and the C# SDK version (`` in `sdk/dotnet/Microsoft.Mxc.Sdk/Microsoft.Mxc.Sdk.csproj`) must match. Run `node scripts/check-version-sync.js` to validate they are in sync. When bumping the version, update `src/Cargo.toml` (workspace version), `sdk/node/package.json`, and the C# csproj in the same commit. @@ -241,6 +256,7 @@ When changing behavior covered by existing documentation, update the relevant do - **SDK API changes** (new exports, changed signatures, new options) → update `sdk/node/README.md` and the JSDoc in `sdk/node/src/index.ts` (TypeScript SDK); the Rust `mxc-sdk` crate docs/`README.md`; and `sdk/dotnet/README.md` (C# SDK). If the `mxc_ffi` C ABI surface changes, the C# P/Invoke regenerates on the next C# build; keep the `ErrorCode` parity + bindings-codegen gates green. - **New containment backends or major backend changes** → update the relevant doc in `docs/` (e.g., `lxc-support/lxc-backend.md`, `windows-sandbox/windows-sandbox.md`) - **Versioning or promotion changes** → update `docs/versioning.md` +- **Telemetry consent or policy changes** → update `docs/telemetry/telemetry-consent-design.md` and/or `docs/telemetry/telemetry-policy.md`, and keep `scripts/check-telemetry-policy-parity.js` green across all three bindings ### Policy versioning diff --git a/docs/telemetry/telemetry-consent-design.md b/docs/telemetry/telemetry-consent-design.md new file mode 100644 index 000000000..3d40e3868 --- /dev/null +++ b/docs/telemetry/telemetry-consent-design.md @@ -0,0 +1,655 @@ +# Telemetry Consent — Feature Spec + +> Status: **Implemented** — this document describes the consent and policy +> contract as shipped, and is maintained alongside the code. It began as a +> feature spec per the "Write a Feature Spec" step in +> [`docs/authoring-a-new-feature.md`](../authoring-a-new-feature.md); the +> section-by-section implementation status is tracked in §9. + +## 1. Problem statement + +MXC already has a fully-built ETW TraceLogging pipeline +(`mxc_telemetry` + `wxc_common::telemetry`, see +[`telemetry.md`](telemetry.md)), gated behind `--experimental` and a +per-request JSON field, `experimental.telemetry.enabled`. Today that field is +the *entire* consent model, and the code says so explicitly +(`wxc_common/src/telemetry/mod.rs`): + +> "Note: Consent is the SDK consumer's responsibility. MXC does not +> implement consent prompts or persistent consent storage." + +That is insufficient for a real end-user consent experience: + +- There is no **persistent** record of a user's choice — every SDK consumer + would have to build (and get right) their own storage, first-run prompt, + and toggle UI, redundantly and inconsistently, or (worse) hardcode + `enabled: true` and never ask anyone. +- Nothing stops a config author from setting `enabled: true` regardless of + whether the person running the sandbox ever agreed to anything. +- There is no cross-platform story: `mxc_telemetry` is already a Windows-only + ETW provider (no-op elsewhere), but the *consent surface* doesn't + explicitly reflect that — a consumer could plausibly wire up an opt-in UI + on Linux/macOS for a pipe that will never collect anything, which is + actively misleading to end users. + +This spec defines a **persistent, per-user, Windows-only telemetry consent +flag**, owned by MXC itself, that: + +1. Gates all telemetry emission — no consent, no data, full stop. +2. Is the single source of truth shared by every SDK surface (Node, C#, + direct `wxc-exec.exe` callers), instead of being reimplemented per consumer. +3. Is offered to end users on first sandbox run, and can be changed at any + time by whatever agent/app is hosting MXC. +4. Does not exist at all — no flag, no prompt, no storage, no API surface + claiming to do anything — on Linux/macOS, because MXC does not and must + not collect telemetry on those platforms. + +## 2. Grounding in Microsoft's privacy principles + +Microsoft's public privacy commitments (Microsoft Privacy Promise / +Trust Center: ) center on +**control, transparency, consent, and security**. Microsoft's public +guidance is also clear that the Windows diagnostic-data setting governs +Windows itself, not separately installed applications: the `AllowTelemetry` +policy "doesn't apply to any additional apps installed by your +organization" +([Microsoft Learn](https://learn.microsoft.com/windows/privacy/configure-windows-diagnostic-data-in-your-organization)). +Consent for an application's own diagnostic data is therefore that +application's responsibility, not something it inherits from the OS. + +MXC is not a Windows inbox component and cannot piggyback on the OS-level +Diagnostic Data setting (`Settings > Privacy > Diagnostics & feedback`); it +must implement and honor its own consent, exactly as any third-party +Windows app or SDK must. This design applies the same pillars end-to-end: + +| Privacy Promise pillar | How this design honors it | +|---|---| +| **Consent** | Telemetry is **off by default** (`Undetermined` ⇒ treated as denied). Nothing is ever collected before an explicit, affirmative "granted". | +| **Control** | The user (or the agent acting on their behalf) can flip the flag at any time, as many times as they like — no re-install, no support ticket. | +| **Transparency** | A `status` query is always available and cheap (local file read, no network call) so any consumer can show the current state and link to [`telemetry.md`](telemetry.md) describing exactly what is collected. | +| **No dark patterns** | Denying is exactly as easy as granting; MXC does not nag on every run once a choice has been made; the consent primitives never bias the wording or defaults toward "on". | +| **Least privilege / data minimization** | Reuses the existing bounded, PII-scrubbed event schema (`MXC.Execution` / `MXC.Error`, see `telemetry.md`) — this spec changes *whether* those events fire, never *what* they contain. | +| **Fail closed** | Any ambiguous state — missing file, corrupt file, unreadable file, unknown platform — resolves to **not collecting**, never to collecting. | +| **Platform honesty** | Non-Windows builds do not merely default the flag to "off" — the consent module does not compile in on non-Windows targets, so there is no code path, storage file, or API pretending consent is meaningful where MXC cannot and does not collect anything. | + +### 2.1 Provider-group classification and why it doesn't change this design + +MXC's ETW provider may be registered under a UTC provider group. Provider +groups affect how already-emitted events are *classified and routed* by the +backend — for example, keeping an application's data separated from Windows +diagnostic data. They say nothing about whose consent gates emission in the +first place. + +Because MXC is an application rather than a Windows system component, it is +responsible for its own notice and consent experience and must not rely on +the Windows diagnostic consent. That responsibility is unaffected by +provider-group choice. + +Regardless of which UTC provider group MXC's ETW provider is registered +under (see `telemetry.md`'s *Private GUID Substitution* section), **the sole +gate for emission is the persisted +`%LOCALAPPDATA%\mxc\telemetry-consent.json` flag described in this document +— never the Windows Diagnostics & feedback setting, never an implicit +UTC-level opt-in.** + +## 3. Design overview + +``` +┌────────────────────────────────────────────────────────────┐ +│ Host application ("agent") using MXC │ +│ - First run: sees needsConsentPrompt == true, shows its │ +│ own UI, calls setTelemetryConsent(...) │ +│ - Any later time: settings page calls get/setTelemetry │ +│ Consent(...) again to flip the choice │ +└───────────────┬──────────────────────────────────────────────┘ + │ SDK call (Node / C#) + ▼ +┌────────────────────────────────────────────────────────────┐ +│ SDK thin wrapper (sdk/node, sdk/dotnet) │ +│ getTelemetryConsent() / setTelemetryConsent(state) │ +└───────────────┬──────────────────────────────────────────────┘ + │ shells out to wxc-exec.exe --telemetry-consent-* + │ (Node) OR P/Invoke into mxc_ffi (C#) + ▼ +┌────────────────────────────────────────────────────────────┐ +│ wxc_common::telemetry::consent (Windows-only module) │ +│ - reads/writes the persisted consent file │ +│ - single source of truth for every surface │ +└───────────────┬──────────────────────────────────────────────┘ + │ + ▼ + %LOCALAPPDATA%\mxc\telemetry-consent.json (per Windows user) + +┌────────────────────────────────────────────────────────────┐ +│ wxc_common::telemetry::policy (Windows-only module) │ +│ - reads the administrative (MDM / Group Policy) ceiling │ +│ - deny-only; never a substitute for consent │ +└───────────────┬──────────────────────────────────────────────┘ + │ + ▼ + HKLM\SOFTWARE\Policies\Mxc\AllowTelemetry (per device) +``` + +Telemetry emission (`wxc_common::telemetry::is_enabled`) becomes: + +``` +effective = platform_is_windows + && admin_policy_permits + && persisted_consent == Granted + && request.experimental.telemetry.enabled != Some(false) +``` + +- Persisted consent is the **gate**. Without `Granted`, nothing fires, + regardless of what a config author put in the per-request JSON. +- The administrative policy is a **ceiling**. It can only ever subtract: an + administrator can stop MXC collecting on a device, but an administrator + permitting collection does not stand in for the user's own decision. See + [`telemetry-policy.md`](telemetry-policy.md) for the full specification. +- The existing `experimental.telemetry.enabled` field becomes an + **explicit opt-in that can only subtract**: collection requires + `true`, while omitting the field or setting `false` always disables it (a + caller can always force telemetry off for one run, e.g. CI, a support + repro, or a policy override); explicit `true` can no longer *bypass* + consent — it is simply ignored if consent isn't `Granted`. This closes the + "hardcode `true` and never ask anyone" loophole described in §1 while + preserving today's emergency-off behavior. +- On non-Windows, `persisted_consent` is not merely "false" — the consent + module is `#[cfg(target_os = "windows")]`-gated out entirely, so the + expression above compiles to the existing Linux/macOS no-op path + unchanged. The policy module is gated the same way and reports + `NotApplicable`, which does not itself deny (there is nothing to deny — + the consent gate has already stopped collection, and reporting a denial + would wrongly imply an administrator had acted). + +No JSON config **schema** change is required: consent is out-of-band, +per-user host state, not a per-run parameter, so `wire.rs` / +`schemas/dev/*.json` are untouched. This also avoids any stable/dev schema +promotion churn. + +## 4. Persisted consent store + +- **Location**: `%LOCALAPPDATA%\mxc\telemetry-consent.json` — per-user, not + `%ProgramData%`. Consent is a personal choice tied to the signed-in + Windows user (mirrors how Windows Diagnostic Data settings and most app + privacy toggles are scoped), and per-user storage means multiple people + sharing one machine each control their own choice independently, with no + admin/elevation requirement to change it (`wxc-exec.exe` does not + self-elevate — see `docs/host-prep.md`). +- **Format** (schema-versioned, forward-compatible, mirrors the + `null-device-acl.log` JSON-lines style already used by `wxc-host-prep`): + + ```json + { + "schemaVersion": 1, + "consent": "granted", + "source": "prompt", + "promptedMxcVersion": "0.9.2", + "updatedAtEpoch": 1785169735 + } + ``` + + - `consent`: `"granted" | "denied" | "undetermined"`. + - `source`: `"prompt" | "settings-toggle" | "cli" | "sdk"` — free-form + provenance for support/debugging, never transmitted anywhere. + - `updatedAtEpoch`: Unix epoch seconds — internal provenance only, never + surfaced through the CLI/FFI/SDK surfaces (those only ever expose + `consent`). + - File is written atomically (write to a temp file in the same directory, + then rename) to avoid a torn read if a crash happens mid-write. +- **Fail-closed reads**: missing file, unreadable file, unparseable JSON, or + an unrecognized `schemaVersion` all resolve to `Undetermined` (⇒ not + collecting) — never to `Granted`. A corrupt file is logged as a + diagnostic (same `Logger` used elsewhere) but never treated as consent. +- **No telemetry about consent itself**: flipping the flag never emits an + ETW event. At the moment of a transition we may be entering *or leaving* + a consented state, so the only safe behavior is silence — this also + avoids a "one last ping on the way out the door" problem when a user + revokes. + +## 5. Surface: `wxc-exec.exe` flags + +Following the existing flag style in `src/core/wxc/src/main.rs` (`--probe`, +`--delete`, `--setup-hyperlight`, …) rather than introducing a clap +subcommand tree: + +| Flag | Behavior | +|---|---| +| `--telemetry-consent-status` | Prints current state as one-line JSON (`{"consent":"granted","needsPrompt":false,"policy":"allowed"}`) and exits. Available on every platform; on non-Windows always prints `{"consent":"not-applicable","needsPrompt":false,"policy":"not-applicable"}` and never touches disk. The payload carries exactly the three things a host needs to act — the user's own decision, whether it should prompt, and the administrative ceiling. `needsPrompt` is emitted rather than left for each SDK to derive from `consent`, so the prompt policy has exactly one implementation (`ConsentState::needs_prompt`, combined with the policy read) shared by every language; in particular a `blocked` policy suppresses the prompt, so `needsPrompt` is never `true` alongside `"policy":"blocked"`. `policy` is one of `unrestricted` (no MDM/Group Policy value configured), `allowed` (configured and permits collection), `blocked` (configured to deny, *or* unreadable — see [`telemetry-policy.md`](telemetry-policy.md)), or `not-applicable` (non-Windows). `source` and the on-disk timestamp are internal provenance never surfaced through this CLI. | +| `--telemetry-consent-grant` | Persists `Granted` (source `"cli"` unless `--telemetry-consent-source ` is passed, e.g. by an SDK wrapper), then prints the same status JSON as above. Windows-only; on non-Windows, exits non-zero (`1`) with `Error: telemetry is Windows-only; consent is not applicable on this platform` — MXC must not pretend to accept consent it can never act on. `--telemetry-consent-grant` and `--telemetry-consent-revoke` are mutually exclusive (exits with code `64` if both are passed). | +| `--telemetry-consent-revoke` | Persists `Denied`, then prints the same status JSON. Same platform behavior as above. | + +All three flags are handled by one shared implementation, +`wxc_common::telemetry::consent_cli::handle_consent_flags`, that each +executor's `main.rs` delegates to — this is a fast path evaluated before any +other startup work, so the flags behave identically across `wxc-exec`, +`lxc-exec`, and `mxc-exec-mac` even though only `wxc-exec` can actually +persist a decision. + +These are detection/administration fast paths, mirroring `--probe`: they run +before COM/runner initialization, do not execute any sandbox, and exit +immediately. This is the "engaging with the agent that is using +`wxc-exec.exe`" toggle mechanism the SDKs call into — the host application's +own settings UI shells out to one of these flags (or calls the SDK wrapper, +which does the same thing under the hood). + +## 6. Surface: Node SDK (`sdk/node`) + +New module `sdk/node/src/telemetry.ts`, re-exported from `index.ts`: + +```ts +export type TelemetryConsentState = 'granted' | 'denied' | 'undetermined' | 'not-applicable'; +export type TelemetryConsentSource = 'prompt' | 'settings-toggle' | 'cli' | 'sdk' | (string & {}); +export type TelemetryPolicyState = 'unrestricted' | 'allowed' | 'blocked' | 'not-applicable'; + +/** Always 'not-applicable' on non-Windows — MXC does not collect telemetry there. Never throws. */ +export function getTelemetryConsent(): TelemetryConsentState; + +/** + * Same as getTelemetryConsent(), but also reports *why* the state is what it is, + * so a host can distinguish "the user genuinely has not decided" (prompt) from + * "we could not reach wxc-exec" (broken install — prompting will not help). + * `needsPrompt` and `policy` come straight from the native layer; the SDK does + * not derive either. This is the single-spawn snapshot the other three read + * functions are thin wrappers over — prefer it when you need more than one + * answer, so all three are consistent with each other. Never throws. + */ +export function queryTelemetryConsent(): TelemetryConsentQuery; + +export interface TelemetryConsentQuery { + state: TelemetryConsentState; + needsPrompt: boolean; + policy: TelemetryPolicyState; + error?: string; +} + +/** The administrative (MDM / Group Policy) ceiling. Fails closed to 'blocked'. Never throws. */ +export function getTelemetryPolicy(): TelemetryPolicyState; + +/** Throws if the decision could not be persisted — always the case on non-Windows. */ +export function setTelemetryConsent(granted: boolean, source?: TelemetryConsentSource): void; + +/** + * Convenience for first-run flows: the native layer's `needsPrompt` answer. + * Always `false` when the policy is `'blocked'` — asking for permission an + * administrator has already refused is a meaningless question. + */ +export function needsTelemetryConsentPrompt(): boolean; +``` + +Both read paths short-circuit on `process.platform !== 'win32'` *before* any +attempt to spawn `wxc-exec`, so a spawn failure on macOS/Linux can never be +reported as `'undetermined'` and can never drive a host into showing a consent +prompt on a platform where MXC collects nothing. The C# SDK's +`MxcTelemetry.GetConsent()`/`SetConsent()` apply the same +`OperatingSystem.IsWindows()` guard before touching the native library. + +Implementation shells out to `wxc-exec.exe --telemetry-consent-*` (the SDK +already resolves the native binary path via `platform.ts`), keeping the +actual persistence logic in exactly one place (Rust). The SDK is +deliberately **UI-agnostic**: it does not render a prompt itself. A hosting +agent calls `needsTelemetryConsentPrompt()` once at first sandbox run, shows +its own UI if `true`, then calls `setTelemetryConsent(...)`; a settings page +can call `get`/`setTelemetryConsent` at any later time. + +None of the read functions throw. On Windows, a missing `wxc-exec` is a broken +install rather than an unsupported platform, so it fails closed to +`'undetermined'` / `'blocked'` and reports why in +`TelemetryConsentQuery.error` — it must not be reported as `'not-applicable'`, +which would tell the host this machine never collects telemetry and hide the +failure. Because the three convenience getters discard `error`, every +fail-closed read is also warned to the console once per distinct failure per +process (deduplicated: a host may poll these to render a settings toggle). + +## 7. Surface: C# SDK (`sdk/dotnet`) + +New `MxcTelemetry` static class wrapping four new `mxc_ffi` exports: + +```rust +// ffi/mxc_ffi +pub unsafe extern "C" fn mxc_telemetry_get_consent(out_utf8: *mut *mut c_char) -> i32; +pub unsafe extern "C" fn mxc_telemetry_set_consent(granted: i32, source_utf8: *const c_char) -> i32; +pub unsafe extern "C" fn mxc_telemetry_needs_consent_prompt(out_needs_prompt: *mut i32) -> i32; +pub unsafe extern "C" fn mxc_telemetry_get_policy(out_utf8: *mut *mut c_char) -> i32; +``` + +All are `catch_unwind`-wrapped like every other `mxc_ffi` entry point — a panic +must never unwind into a foreign frame, which would be undefined behaviour. +Because `catch_unwind` otherwise discards the payload and leaves the host with +a bare `MXC_STATUS_PANIC` and no way to diagnose it, the boundary writes the +panic message to stderr before returning. The same applies to the write-failure +reason in `mxc_telemetry_set_consent`, which the status code alone cannot +convey (missing profile directory, denied ACL, read-only volume). +`mxc_telemetry_get_consent` always succeeds and reports `"not-applicable"` +on non-Windows (it never fails because of platform). `mxc_telemetry_set_consent` +returns `MXC_STATUS_CONSENT_WRITE_FAILED` when the decision can't be +persisted — always the case on non-Windows, since MXC must not collect (and +therefore must not offer consent for) telemetry there. +`mxc_telemetry_needs_consent_prompt` exists so C# does not re-derive the +prompt policy from the state string; it writes `0` on non-Windows. +`mxc_telemetry_get_policy` reports the administrative ceiling and likewise +always succeeds, reporting `"not-applicable"` on non-Windows. All compile on +every platform so `NativeMethods.g.cs` stays uniform across OSes. + +```csharp +public static class MxcTelemetry +{ + public static TelemetryConsentState GetConsent(); + public static void SetConsent(bool granted, string? source = null); + public static bool NeedsConsentPrompt(); + public static TelemetryPolicyState GetPolicy(); +} +``` + +`NeedsConsentPrompt()` and `GetPolicy()` **never throw at all** — they fail +closed to `false` / `Blocked` on any failure, including a non-`Success` status +from the native layer (which covers a caught panic). A read-only query on a +privacy gate must not be able to take down a host that calls it on its startup +path. + +`GetConsent()` also fails closed to `Undetermined` for a missing, mismatched, or +unloadable native library, but still surfaces a genuine native-layer failure as +`MxcException` — its caller has to be able to tell "the user has not decided" +apart from "we could not read the decision", or it would prompt on a broken +install. `SetConsent()` likewise throws, since the caller asked to persist a +decision and silence would be a lie. + +Both throwing paths only ever raise `MxcException`: an unexpected exception +from the marshalling layer is wrapped (preserving the original as +`InnerException`) rather than escaping raw, so a host catching the documented +type is not taken down by a surprise one. + +Every swallowed failure is reported once per distinct failure per process on +stderr — otherwise a broken install is completely silent, since the fail-closed +return values are indistinguishable from legitimate ones. The reporter cannot +itself throw. + +## 7a. Surface: Rust SDK (`src/core/mxc-sdk`) + +`mxc-sdk` — the public Rust SDK — re-exports the consent and policy API +verbatim from `wxc_common::telemetry`, so a Rust consumer gets the same +operations as a Node or C# consumer: + +```rust +pub mod telemetry { + pub use wxc_common::telemetry::consent::{ + get_consent, needs_consent_prompt, set_consent, ConsentState, + }; + pub use wxc_common::telemetry::policy::{get_policy, is_blocked_by_policy, PolicyState}; +} +``` + +This is a **pure re-export** — there is deliberately no Rust-SDK-specific +consent logic to keep in sync. A Rust host calls `needs_consent_prompt()` +at first sandbox run, shows its own UI, then calls `set_consent(..)`, and +can call `get_consent()`/`set_consent(..)` from a settings surface later — +exactly the flow described in §8. + +## 8. First-run flow (end to end) + +1. Host application calls `needsTelemetryConsentPrompt()` (or the C#/CLI + equivalent) once, e.g. right before its first `spawnSandbox` call. +2. If `true` (Windows + `Undetermined`), the host shows **its own** consent + UI — MXC does not ship a UI, since it is a library used by arbitrary + host apps/agents with their own look and feel and localization needs. + The UI copy should point at `docs/telemetry/telemetry.md` (or the host's + own equivalent) so the "transparency" pillar is satisfied with concrete, + specific information, not a vague "help us improve" prompt. +3. The host calls `setTelemetryConsent(true|false)` with the user's answer. + This persists to `%LOCALAPPDATA%\mxc\telemetry-consent.json` for that + Windows user and is immediately effective for every subsequent + `wxc-exec.exe` invocation (one-shot and state-aware) run by that user — + no restart, no cache to invalidate, since `is_enabled()` re-reads the + file at each process's `telemetry::init()`. +4. On Linux/macOS, `needsTelemetryConsentPrompt()` always resolves `false` + — hosts never see a prompt opportunity, satisfying the hard requirement + that consent must not even be *offered* off Windows. +5. At any later time — a settings/preferences screen, a CLI flag, an admin + tool — the host calls `setTelemetryConsent` again to flip the choice. + There is no limit on how often this can change; every call is a plain, + idempotent, atomic file write. + +## 9. Files touched (implementation checklist) + +| File | Change | Status | +|---|---|---| +| `src/core/wxc_common/src/telemetry/consent.rs` (new) | `ConsentState` enum, `read_consent()`/`write_consent()`, atomic-write helper, fail-closed parsing. `#[cfg(target_os = "windows")]` real impl + stub for other targets that always returns `NotApplicable` and never touches disk. | ✅ Done | +| `src/core/wxc_common/src/telemetry/mod.rs` | `is_enabled()` updated to the new resolution order in §3; doc comment correction (remove the "MXC does not implement consent" note, replace with a pointer to this design). | ✅ Done | +| `src/core/wxc/src/main.rs` | Add `--telemetry-consent-status` / `--telemetry-consent-grant` / `--telemetry-consent-revoke` (+ `--telemetry-consent-source`) flags, handled as an early fast path like `--probe`. | ✅ Done | +| `src/core/lxc/src/main.rs`, `src/core/mxc_darwin/src/main.rs` | Add the same flags for CLI symmetry; always report/act `not-applicable` (never write a file, never accept "grant"). | ✅ Done | +| `src/core/mxc-sdk/src/lib.rs` | `pub mod telemetry` re-exporting `get_consent` / `set_consent` / `needs_consent_prompt` / `ConsentState` from `wxc_common`, so the public **Rust** SDK offers the same consent surface as the Node and C# SDKs. Pure re-export — no Rust-SDK-specific logic. | ✅ Done | +| `src/core/wxc_common/src/telemetry/consent.rs` | `ConsentState::needs_prompt()` + free fn `needs_consent_prompt()` — the single definition of the prompt policy for every consumer surface. | ✅ Done | +| `ffi/mxc_ffi/src/lib.rs` | `mxc_telemetry_get_consent` / `mxc_telemetry_set_consent` / `mxc_telemetry_needs_consent_prompt` exports; new `MXC_STATUS_CONSENT_WRITE_FAILED` status code. | ✅ Done | +| `sdk/node/src/telemetry.ts` (new) | `getTelemetryConsent`, `queryTelemetryConsent`, `setTelemetryConsent`, `needsTelemetryConsentPrompt`, `TelemetryConsentState`/`TelemetryConsentSource`/`TelemetryConsentQuery` types, all behind a `process.platform === 'win32'` guard. | ✅ Done | +| `sdk/node/src/index.ts` | Re-export the above. | ✅ Done | +| `sdk/node/README.md` | Document the consent API and the first-run flow. | ✅ Done | +| `sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs`, `TelemetryConsentState.cs` (new) | `GetConsent()` / `SetConsent(bool, string?)` / `NeedsConsentPrompt()` wrapping the new FFI exports. | ✅ Done | +| `sdk/dotnet/README.md` | Same documentation as the Node README. | ✅ Done | +| `docs/telemetry/telemetry.md` | New "## Consent" section describing the persisted flag, replacing the old "consent is the SDK consumer's responsibility" note; also documents why provider-group classification does not affect the consent gate. | ✅ Done | +| `scripts/check-dotnet-errorcode-parity.js` | No code change needed — it already diffs `MXC_STATUS_*` against `ErrorCode.cs` generically. | ✅ Verified passing | +| `scripts/check-dotnet-bindings-codegen.js` | Extended `REQUIRED_ENTRY_POINTS` with the three new FFI exports. | ✅ Done | +| Tests (`wxc_common`, `mxc_ffi`, `sdk/node/tests`, `sdk/dotnet` tests) | See §10. | ✅ Done | +| `tests/scripts/run_telemetry_consent_smoke_test.ps1` (new) | Standalone CLI smoke test: grant/status/revoke/status round-trip + mutual-exclusion rejection, against a consent store isolated via `MXC_TEST_LOCALAPPDATA_OVERRIDE`. Debug builds only (release compiles the override out); the script asserts the redirect took effect before trusting any result. | ✅ Done | + +## 10. Test plan + +- **Unit (`wxc_common`, Windows-only `#[cfg(target_os = "windows")]` tests, + run in the existing Windows CI job)**: + - Fresh machine (no file) ⇒ `Undetermined`, `is_enabled() == false`. + - Grant ⇒ persists, re-read returns `Granted`, `is_enabled() == true` + (with `experimental.telemetry.enabled` set to `true`). + - Grant + request omits `enabled` or sets it to `false` ⇒ `is_enabled() + == false` (collection requires an explicit opt-in). + - Deny ⇒ persists, `is_enabled() == false` even if request sets + `enabled: true` (consent gates; config can't bypass it). + - Corrupt file / unknown `schemaVersion` / unreadable file ⇒ fail closed + to `Undetermined`. + - Atomic write: simulate a crash between temp-write and rename (best + effort — assert the original file is untouched if rename never + happens). + - **Status: implemented in `consent.rs` and `telemetry/mod.rs`; 464/464 + `wxc_common` tests pass, including the full matrix above.** +- **Unit (non-Windows, run in Linux/macOS CI jobs)**: + - `consent::read()` compiles to the stub and returns `NotApplicable` + without creating any file or directory. + - `is_enabled()` remains `false` unconditionally, matching today's + behavior. + - **Status: covered by the same cross-platform test module (the stub path + compiles and is exercised via `cfg`-gated assertions); full behavioral + verification on non-Windows hosts is deferred to the Linux/macOS CI + matrix, which already runs `cargo test --workspace`.** +- **CLI smoke test** (`tests/scripts/run_telemetry_consent_smoke_test.ps1`, + Windows): round-trip `--telemetry-consent-grant` → + `--telemetry-consent-status` → `--telemetry-consent-revoke` → + `--telemetry-consent-status`, asserting the JSON output at each step; + verify the file lands under the isolated store directory. + - Isolation uses the debug-only `MXC_TEST_LOCALAPPDATA_OVERRIDE` env var, + not `LOCALAPPDATA` (production never reads `LOCALAPPDATA` — it resolves + the known folder directly, so that an attacker who can set an env var + cannot redirect the consent store). The script therefore refuses to run + against a release binary, and fails loudly if the first write does not + land under the temp directory. + - **Status: implemented and passing** (also verified the mutual-exclusion + rejection of `--telemetry-consent-grant` + `--telemetry-consent-revoke`). +- **SDK unit tests**: `sdk/node/tests/unit/telemetry.test.ts` mocking the + child-process call; assert `needsTelemetryConsentPrompt()` is always + `false` when `platform.ts` reports non-Windows, without invoking the + binary at all. + - Also asserts, per non-Windows platform, that `getTelemetryConsent()` + returns `not-applicable`, `needsTelemetryConsentPrompt()` is `false`, and + `setTelemetryConsent()` throws — all *without* the injected runner ever + being called, so a runner failure can never be mistaken for + `undetermined` off Windows. + - **Status: implemented (21 tests), wired into `npm run test:unit`; full + 213-test Node suite passes.** +- **C# tests**: round-trip Get/SetConsent against the real `mxc_ffi` + build, matching the existing `Microsoft.Mxc.Sdk.Tests` pattern. + - The fixture redirects the store via `MXC_TEST_LOCALAPPDATA_OVERRIDE` and + then *verifies* the redirect took effect with two read-only probes + (write `granted` to the temp store, expect `Granted`; write `denied`, + expect `Denied` — the real store cannot be both), failing loudly rather + than silently mutating the real per-user consent file when the native + library under test is a release build. + - **Status: implemented in `MxcTelemetryTests.cs` (9 tests, including the + native-load-failure classification matrix); full 14-test C# suite passes.** +- **Regression**: existing `28_telemetry_enabled.json` example continues to + document the config field; add a note (or a second example) showing that + the field alone, without persisted consent, produces no telemetry. + - **Status: not yet done — tracked as a follow-up.** + +### Test isolation conventions + +Two process-global resources back the Windows tests — the consent store +directory (`MXC_TEST_LOCALAPPDATA_OVERRIDE`) and the policy key +(`MXC_TEST_POLICY_KEY_OVERRIDE`). Each is guarded by its own mutex, so a +test that needs both can deadlock against a test that takes them in the +opposite order. + +- **`telemetry::test_support::TelemetryTestEnv`** is the only sanctioned way + to hold both. It acquires the policy guard first and the consent guard + second, and its fields are declared so that Rust's declaration-order drop + releases them in exactly the reverse order. Never construct + `PolicyKeyGuard` and `LocalAppDataGuard` directly in the same test. +- Any test that reads consent or policy state must hold the corresponding + guard, even if it "only reads" — otherwise it reads the real machine's + state and is non-deterministic on a managed device. +- The `wxc_common` **`test-support`** cargo feature re-exports + `telemetry::policy::test_support` outside the crate's own `cfg(test)` + build, so downstream crates (`mxc_ffi`) can drive the policy override from + their integration tests. It is a dev-dependency-only feature; nothing in a + shipping build enables it, and the override itself remains + `cfg(any(test, debug_assertions))`-gated regardless. +- Both overrides are gated on `cfg(any(test, debug_assertions))`, not + `cfg(debug_assertions)` alone. `cfg(test)` is what keeps them present under + `cargo test --release`, which is the only way CI runs tests — a + `debug_assertions`-only gate would silently drop every consent and policy + test from CI *and* leave them reading and overwriting the developer's real + consent store and the real machine policy. Neither condition holds for a + binary MXC ships (not compiled with `--test`, `debug_assertions` off), so + a release `wxc-exec.exe` still resolves only the real known-folder store + and the real `HKLM` policy key. + +## 11. Explicitly out of scope for this spec + +- Any UI/prompt widget shipped by MXC itself — the SDKs stay UI-agnostic; + hosting agents own presentation. +- Any administrative policy that could *grant* consent on a user's behalf. + MXC now honors a machine-wide administrative policy, but strictly as a + deny-only ceiling (see §12.1 and [`telemetry-policy.md`](telemetry-policy.md)); + no policy value causes collection to begin without the user's own decision. +- Reading Windows' own diagnostic-data consent or the Windows + `AllowTelemetry` policy. Permanently out of scope: Microsoft documents that + the Windows policy "doesn't apply to any additional apps installed by your + organization", and the supported OS evaluation APIs deliberately fold in the + user's Settings-app choice, which MXC must not consume. +- Any change to the *content* of `MXC.Execution` / `MXC.Error` events — + this spec only changes the gate in front of the existing, already + PII-reviewed schema. +- Linux/macOS telemetry of any kind — explicitly and permanently not a goal. + +## 12. Resolved decisions + +All questions raised for review have been decided. Recorded here so the +rationale survives, and so a future change knows what it would be +reversing. + +### Governing rule: policy may restrict, never substitute for, consent + +Stated first because it governs every numbered decision below. This is a +ratified product rule, not an inference from external guidance. + +An administrative policy is a deny-only ceiling: it can subtract from what a +user permitted, never add to it. An administrator cannot opt a user in. +Concretely, **if the user has opted out and the policy permits collection, the +result is opt-out.** + +The full consent × policy matrix, all of which is enforced by the single +conjunction in `wxc_common::telemetry::is_enabled` and locked in by +`is_enabled_false_when_consent_denied_under_every_policy` and +`is_enabled_false_when_consent_undetermined_under_every_policy`: + +| Consent \ Policy | absent (unrestricted) | `0` / `1` (blocked) | `3` (allowed) | +|---|---|---|---| +| Granted | **collect** | no | **collect** | +| Denied | no | no | **no** ← policy cannot opt the user back in | +| Undetermined | no | no | **no** ← policy is not a substitute for a decision | + +Every cell that collects requires an explicit user grant. There is no policy +value, and no combination of policy and config, that produces collection +without one. + +1. **Consent scope is per-user, permanently.** The store stays at + `%LOCALAPPDATA%\mxc\telemetry-consent.json`. Consent is a property of the + person whose data it is, not of the machine or the tenant, so there is no + machine-wide way to record or override a *decision*, and each user of a + shared machine decides independently. + + **Revised (superseding the original "no machine-wide override at all"):** + an enterprise administrator *can* now prevent MXC from collecting on a + device, via the `HKLM\SOFTWARE\Policies\Mxc\AllowTelemetry` + policy. This does not weaken the guarantee the original decision was + protecting, because the policy is **deny-only**: it can subtract from what + a user permitted, never add to it. An administrator still cannot force + telemetry on, and on an unmanaged/BYOD machine (no policy present) the + behaviour is exactly as originally specified. See + [`telemetry-policy.md`](telemetry-policy.md) for the full design and the + reasoning for why an administrative "allow" is not consent. +2. **The policy key omits the `Microsoft` segment, on purpose.** The key is + `HKLM\SOFTWARE\Policies\Mxc`, not `HKLM\SOFTWARE\Policies\Microsoft\Mxc`, + even though MXC is a Microsoft product. Windows refuses to let an + ADMX-ingested policy write under `System`, `Software\Microsoft`, or + `Software\Policies\Microsoft`, except for a hardcoded allowlist (Office, + Edge, OneDrive, VisualStudio, …). MXC is not on that list and cannot join + it without a Windows servicing change, so a key under `Policies\Microsoft` + would make `Mxc.admx` un-ingestible by Intune and every other MDM — + leaving administrators with only scripts and Win32 packages. + + `SOFTWARE\Policies\` is the shape Microsoft's own ingestion + documentation uses for third-party apps. Security is unaffected: + `HKLM\SOFTWARE\Policies` is administrator/SYSTEM-write and user-read by + default, and subkeys inherit that, so a standard user still cannot forge a + permit. Note that `Software\Policies\Microsoft\VisualStudio` *is* on the + allowlist — sibling Microsoft developer tools under `Policies\Microsoft` + are there by explicit exemption, which is not a precedent MXC can inherit. + + This was settled before the first release precisely so that no deployed + policy would ever have to be migrated. Moving the key now would be a + breaking change to the administrator-facing contract. +3. **The CLI flags require neither elevation nor `--experimental`.** + `--telemetry-consent-status` is read-only and informational; + `--telemetry-consent-grant`/`-revoke` write only inside the invoking + user's own `%LOCALAPPDATA%`. Withdrawing consent must never be harder + than granting it, so neither may be gated behind relaunching elevated + or passing an experimental flag. +4. **Flag naming is `--telemetry-consent-*`.** Ratified as shipped; it + reads unambiguously alongside the existing `MXC_TELEMETRY` env var. +5. **`--telemetry-consent-status` carries `needsPrompt` and `policy` + alongside `consent`.** The payload is not "just the state" — it is "the + state, whether to prompt, and the administrative ceiling". Emitting the + extra fields is what lets the prompt policy and the policy evaluation each + have exactly one implementation (`ConsentState::needs_prompt`, + `telemetry::policy::get_policy`) across Rust, C#, Node, and the CLI, + rather than one copy per language. See §5. +6. **A blocking policy suppresses the consent prompt but preserves the + recorded consent.** Asking a user to permit what an administrator has + already refused is a meaningless question, so `needsPrompt` reports + `false`. But the stored decision is left untouched, so relaxing the policy + later restores the user's real choice instead of re-prompting them. + +### Known gaps (tracked, deliberately not fixed here) + +- `mxc_ffi` still duplicates `wxc_common`'s *consent* test harness: + `wxc_common::telemetry::consent::test_support` remains `#[cfg(test)] + pub(crate)` and so is not compiled into dependent crates. The *policy* + harness is no longer duplicated — `telemetry::policy::test_support` is + shared through the `test-support` cargo feature — and extending the same + feature to cover the consent harness is the remaining work. Tracked as + [#690](https://github.com/microsoft/mxc/issues/690). +- The consent CLI smoke test depends on `MXC_TEST_LOCALAPPDATA_OVERRIDE` + and `MXC_TEST_POLICY_KEY_OVERRIDE`, which are compiled out of shipping + builds. `wxc_common`'s own unit tests keep them via `cfg(test)` and so run + under `cargo test --release`, but cross-crate consumers (`mxc_ffi`'s Rust + tests, the C# and Node suites driving a native binary) only get them from a + debug build, so their consent/policy coverage is debug-only. Tracked as + [#691](https://github.com/microsoft/mxc/issues/691). + + diff --git a/docs/telemetry/telemetry-policy.md b/docs/telemetry/telemetry-policy.md new file mode 100644 index 000000000..ad251f0d8 --- /dev/null +++ b/docs/telemetry/telemetry-policy.md @@ -0,0 +1,232 @@ +# MXC administrative telemetry policy + +Audience: IT administrators, and developers embedding MXC in a product that +ships to managed devices. + +MXC supports a single administrative policy that lets an organization prevent +MXC from collecting diagnostic data on a device, regardless of what the user +chooses. It is a **ceiling, never a grant** — see +[Why policy cannot substitute for consent](#why-policy-cannot-substitute-for-consent). + +The policy is **Windows-only**, because [MXC only ever collects telemetry on +Windows](telemetry.md). On Linux and macOS there is nothing to restrict. + +## The setting + +| | | +|---|---| +| Key | `HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Mxc` | +| Value name | `AllowTelemetry` | +| Type | `REG_DWORD` | +| Scope | Machine (all users on the device) | + +`HKLM\SOFTWARE\Policies\...` is the standard managed-policy hive: it is +writable only by administrators and is the location Group Policy, Intune, and +other MDM services write to. MXC deliberately does not honor a per-user +(`HKCU`) equivalent — a policy a user can edit is not a policy. + +### Values + +The values mirror the Windows diagnostic-data scale so administrators do not +have to learn a second one: + +| Value | Windows name | Effect on MXC | +|-------|--------------|---------------| +| *(value absent)* | — | **Unrestricted.** MXC is unmanaged; the user's own choice decides. | +| `0` | Security / Off | **Blocked.** MXC collects nothing. | +| `1` | Required (Basic) | **Blocked.** MXC collects nothing. | +| `3` | Optional (Full) | **Allowed.** MXC may collect *if the user has also consented.* | +| anything else | — | **Blocked** (fail closed). | + +`1` blocks MXC because everything MXC emits is classified as +*product-and-service-usage* data — optional diagnostic data in Windows' +taxonomy. MXC emits no required diagnostic data, so there is nothing left to +send at level `1`. Value `2` is not a defined level on modern Windows and is +therefore treated as unrecognized. + +Any value MXC cannot read or cannot parse — a wrong type, a corrupt value, a +registry error — is treated as `Blocked`. Telemetry collection is never the +outcome of a failure. + +## Deploying it + +### Group Policy (native, works today) + +There is no inbox ADMX for MXC yet. Either use Group Policy Preferences to +write the registry value, or import the ADMX below. + +
+Mxc.admx + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +The matching `Mxc.adml` needs `MXC`, `AllowTelemetry`, `AllowTelemetry_Help`, +`Off`, `Required` and `Optional` strings plus a `dropdownList` presentation for +`AllowTelemetry_Enum`. + +
+ +### Microsoft Intune + +Import `Mxc.admx` (above) via **Devices → Configuration → Import ADMX**, then +create a Configuration profile from the imported template and set +**AllowTelemetry**. + +MXC's policy key lives at `SOFTWARE\Policies\Mxc` — deliberately *not* under +`Policies\Microsoft` — specifically so that this works. Windows forbids +ADMX-ingested policies from writing under `System`, `Software\Microsoft`, or +`Software\Policies\Microsoft`, except for a hardcoded allowlist (Office, Edge, +OneDrive, VisualStudio, …). An ADMX targeting a key under those prefixes is +rejected. See [Win32 and Desktop Bridge app ADMX policy +ingestion](https://learn.microsoft.com/windows/client-management/win32-and-centennial-app-policy-configuration). + +Equivalent alternatives, if you would rather not ingest an ADMX: + +- **A PowerShell script** (Devices → Scripts and remediations), running in the + system context: + + ```powershell + $key = 'HKLM:\SOFTWARE\Policies\Mxc' + New-Item -Path $key -Force | Out-Null + Set-ItemProperty -Path $key -Name 'AllowTelemetry' -Value 0 -Type DWord + ``` + +- **A Win32 app or configuration package** that writes the same value. +- **On-premises Group Policy**, for hybrid-joined devices. + +### Verifying + +Any MXC surface will report the effective policy. From the command line: + +``` +wxc-exec.exe --telemetry-consent-status +``` + +```json +{"consent":"granted","needsPrompt":false,"policy":"blocked"} +``` + +The `policy` field is one of `unrestricted`, `allowed`, `blocked`, or +`not-applicable` (returned on non-Windows platforms). The same value is +available programmatically as `mxc_sdk::telemetry::get_policy()` (Rust), +`MxcTelemetry.GetPolicy()` (C#), and `getTelemetryPolicy()` (Node). + +## How the policy interacts with user consent + +MXC collects diagnostic data only when **every** gate is open: + +``` +collect = policy_permits AND user_consented AND build_has_telemetry_enabled +``` + +Concretely: + +| Policy | User consent | Result | +|--------|--------------|--------| +| unrestricted | granted | collects | +| unrestricted | denied / undetermined | **no collection** | +| allowed (`3`) | granted | collects | +| allowed (`3`) | denied / undetermined | **no collection** | +| blocked (`0`/`1`/other) | *anything* | **no collection** | + +Two consequences worth calling out: + +1. **A blocking policy suppresses the first-run consent prompt.** Asking a user + to permit something the administrator has already refused is a question with + no meaning. `needsPrompt` reports `false` while the policy blocks. +2. **A blocking policy does not erase the user's recorded choice.** If a user + had already consented and an administrator later blocks telemetry, + collection stops immediately but the recorded consent is preserved. If the + policy is later relaxed, the user's own prior decision takes effect again + rather than the user being re-prompted. + +### Why policy cannot substitute for consent + +An administrator setting `AllowTelemetry=3` does **not** cause MXC to start +collecting. It only removes MXC's administrative restriction; the user is still +asked, and still decides. + +In particular, **if the user has opted out and the policy permits collection, +the result is opt-out.** A policy can only ever subtract. There is no policy +value, and no combination of policy and configuration, that causes MXC to +collect from a user who has not explicitly granted consent — including a user +who has never been asked. + +This is a product rule MXC holds itself to, not merely a reading of external +guidance. It is also consistent with that guidance: Microsoft's privacy +direction for components classified as *apps* (rather than as parts of the +operating system) requires them to build their own notice-and-consent +experience and not to rely on the Windows diagnostic-data consent. An +administrative policy is an availability control, not an expression of a +user's informed choice, and no Microsoft guidance treats an admin "allow" as +consent on the user's behalf. + +The rule is enforced in one place — the conjunction in +`wxc_common::telemetry::is_enabled` — and is locked in by tests that assert a +denied or undetermined consent wins under *every* policy value, including `3`. + +## Relationship to the Windows `AllowTelemetry` policy + +MXC reads **only** its own key. It does not read, and is not affected by: + +- `HKLM\SOFTWARE\Policies\Microsoft\Windows\DataCollection\AllowTelemetry` +- `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\DataCollection\AllowTelemetry` +- the Windows Settings → Diagnostics & feedback choice +- the `System/AllowTelemetry` Policy CSP node + +Two reasons: + +1. **Microsoft documents that it doesn't apply.** The Policy CSP documentation + for `System/AllowTelemetry` states that it "impacts the operating system and + apps that are considered part of Windows and doesn't apply to any additional + apps installed by your organization." MXC is such an app. +2. **Reading it would leak the user's Windows choice into MXC.** The supported + OS APIs for evaluating that policy deliberately combine the administrative + setting with the user's own Settings-app selection. Consuming them would + make MXC's behaviour depend on Windows consent state, which MXC's design + forbids. + +The dominant pattern among Microsoft first-party applications — Office, +Visual Studio, Visual Studio Code, WinGet, PowerToys — is likewise an +application-specific policy under `SOFTWARE\Policies\Microsoft\`. + +## See also + +- [Telemetry overview](telemetry.md) +- [Telemetry consent design](telemetry-consent-design.md) diff --git a/docs/telemetry/telemetry.md b/docs/telemetry/telemetry.md index 6de0c1117..4f3d11307 100644 --- a/docs/telemetry/telemetry.md +++ b/docs/telemetry/telemetry.md @@ -231,6 +231,63 @@ free-form text. | Linux | No-op — all telemetry functions return immediately | | macOS | No-op — all telemetry functions return immediately | +## Consent + +Telemetry emission is gated by a **persisted, MXC-owned consent flag**, not +just the `experimental.telemetry.enabled` config field. See +[`docs/telemetry/telemetry-consent-design.md`](telemetry-consent-design.md) +for the full design; the summary: + +- Consent state (`granted` / `denied` / `undetermined`) is stored per-user at + `%LOCALAPPDATA%\mxc\telemetry-consent.json`, owned entirely by + `wxc_common::telemetry::consent`. It is **never** derived from, synced + with, or read from any Windows-level setting — not the OS Diagnostic Data + level (Settings → Privacy → Diagnostics & feedback), not a UTC opt-in + policy, nothing. MXC is an application, not a Windows system component, so + it is responsible for its own notice-and-consent experience (see + [Provider group vs. consent](#provider-group-vs-consent) below). +- `wxc_common::telemetry::is_enabled()` resolves to + `config.enabled == Some(true) && policy::get_policy().allows_collection() && consent::get_consent().allows_collection()`: + the JSON config field is an **explicit per-invocation opt-in that can only + subtract** — omitting it or setting `false` disables collection, and `true` + can never bypass a lack of consent. Only `ConsentState::Granted` allows + collection; `Undetermined`, `Denied`, and `NotApplicable` (non-Windows) all + block it. +- An administrator can additionally *block* collection device-wide via + `HKLM\SOFTWARE\Policies\Mxc\AllowTelemetry` — MXC's **own** + policy, not Windows'. It is a deny-only ceiling: it can subtract from what + the user permitted but can never stand in for the user's consent. See + [`docs/telemetry/telemetry-policy.md`](telemetry-policy.md). +- `wxc-exec.exe` exposes `--telemetry-consent-status` / `--telemetry-consent-grant` + / `--telemetry-consent-revoke` (+ `--telemetry-consent-source`) so a hosting + agent/SDK can prompt on first run and let the end user flip the decision at + any later time. `lxc-exec` and `mxc-exec-mac` accept the same flags for + CLI-surface parity but always report `not-applicable` and refuse + grant/revoke — MXC must not gather telemetry, and therefore must not offer + a consent toggle, on any non-Windows platform. +- Flipping consent never itself emits telemetry (no "last ping on the way + out" when revoking). + +### Provider group vs. consent + +MXC's ETW provider may be registered under a UTC provider group. A provider +group only affects how already-emitted events are *classified and routed* — +for example, keeping an application's data separated from Windows diagnostic +data. It does not determine whose consent gates emission. + +MXC is an application, not a Windows system component, so it is responsible +for its own notice and consent experience and must not rely on the Windows +diagnostic consent. Regardless of which UTC provider group its ETW provider +is ultimately classified under (see *Private GUID Substitution* below), +**the gate is always MXC's own persisted consent flag, never the Windows +Diagnostics & feedback setting.** + +The same reasoning is why MXC's administrative policy is its own key rather +than the Windows `AllowTelemetry` policy: Microsoft documents that the +Windows policy "doesn't apply to any additional apps installed by your +organization", and the supported OS APIs for evaluating it deliberately +combine it with the user's Settings-app choice — which MXC must not read. + ## Private GUID Substitution (Internal Builds) MXC supports an optional Microsoft telemetry group GUID for internal builds. diff --git a/schemas/dev/mxc-config.schema.0.8.0-dev.json b/schemas/dev/mxc-config.schema.0.8.0-dev.json index 9a2657fbd..9865fd69e 100644 --- a/schemas/dev/mxc-config.schema.0.8.0-dev.json +++ b/schemas/dev/mxc-config.schema.0.8.0-dev.json @@ -918,7 +918,7 @@ "description": "Telemetry configuration (`experimental.telemetry`).", "properties": { "enabled": { - "description": "Explicit telemetry override. `true` = force on, `false` = force off, omitted = disabled (default off).", + "description": "Explicit telemetry opt-in for this invocation. `true` = opt in (still subject to the user's consent and to administrative policy — it can never turn telemetry on for someone who has not consented), `false` = force off, omitted = off.", "type": [ "boolean", "null" diff --git a/scripts/check-dotnet-bindings-codegen.js b/scripts/check-dotnet-bindings-codegen.js index 3e174f786..51879b990 100644 --- a/scripts/check-dotnet-bindings-codegen.js +++ b/scripts/check-dotnet-bindings-codegen.js @@ -34,6 +34,10 @@ const REQUIRED_ENTRY_POINTS = [ "mxc_run_result_free", "mxc_string_free", "mxc_version", + "mxc_telemetry_get_consent", + "mxc_telemetry_set_consent", + "mxc_telemetry_needs_consent_prompt", + "mxc_telemetry_get_policy", ]; // Remove any stale copy so we prove codegen actually (re)produces it. diff --git a/scripts/check-telemetry-policy-parity.js b/scripts/check-telemetry-policy-parity.js new file mode 100644 index 000000000..ff187141d --- /dev/null +++ b/scripts/check-telemetry-policy-parity.js @@ -0,0 +1,150 @@ +#!/usr/bin/env node +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Validates that the telemetry policy-state wire strings agree across every +// language binding. The Rust `PolicyState::as_str()` in wxc_common is the +// single source of truth; the C# `TelemetryPolicyState` mapping and the +// TypeScript `TelemetryPolicyState` union must cover exactly the same set. +// +// Without this gate the three mappings drift silently: a new Rust variant +// would be parsed as `Blocked` by the other bindings (they fail closed, which +// is safe but wrong), and a stale binding string would never be reported at +// all. Run from the repository root: +// +// node scripts/check-telemetry-policy-parity.js + +const { readFileSync } = require("fs"); +const { join } = require("path"); + +const repoRoot = join(__dirname, ".."); + +const rustPath = join( + repoRoot, + "src", + "core", + "wxc_common", + "src", + "telemetry", + "policy.rs" +); +const csharpPath = join( + repoRoot, + "sdk", + "dotnet", + "Microsoft.Mxc.Sdk", + "MxcTelemetry.cs" +); +const tsPath = join(repoRoot, "sdk", "node", "src", "telemetry.ts"); + +const errors = []; + +// --- Rust: the source of truth ------------------------------------------- +// `PolicyState::Unrestricted => "unrestricted",` inside `fn as_str`. +const rustSrc = readFileSync(rustPath, "utf8"); +const asStrBody = rustSrc.match( + /fn as_str\(&self\)\s*->\s*&'static str\s*\{[\s\S]*?match self \{([\s\S]*?)\n\s*\}/ +); +if (!asStrBody) { + console.error("ERROR: could not find `PolicyState::as_str` in policy.rs"); + process.exit(1); +} +const rustStates = new Map(); // wire string -> Rust variant +for (const m of asStrBody[1].matchAll( + /PolicyState::(\w+)\s*=>\s*"([a-z-]+)"/g +)) { + rustStates.set(m[2], m[1]); +} + +if (rustStates.size === 0) { + console.error("ERROR: parsed zero policy states from policy.rs"); + process.exit(1); +} + +// --- C#: the ParsePolicyState switch -------------------------------------- +// Every state except the fail-closed default must appear as a literal arm; +// the default arm is what maps everything else (including `"blocked"`) to +// Blocked, so `blocked` is expected to be absent from the explicit arms. +const csharpSrc = readFileSync(csharpPath, "utf8"); +const parseBody = csharpSrc.match( + /ParsePolicyState\(string\? value\) => value switch\s*\{([\s\S]*?)\};/ +); +if (!parseBody) { + console.error("ERROR: could not find `ParsePolicyState` in MxcTelemetry.cs"); + process.exit(1); +} +const csharpStates = new Set(); +for (const m of parseBody[1].matchAll(/"([a-z-]+)"\s*=>/g)) { + csharpStates.add(m[1]); +} +const csharpDefault = /_\s*=>\s*TelemetryPolicyState\.Blocked/.test( + parseBody[1] +); +if (!csharpDefault) { + errors.push( + "C# ParsePolicyState must fail closed with `_ => TelemetryPolicyState.Blocked`" + ); +} +// The default arm covers "blocked", so treat it as handled. +csharpStates.add("blocked"); + +// --- TypeScript: the exported union --------------------------------------- +const tsSrc = readFileSync(tsPath, "utf8"); +const unionMatch = tsSrc.match( + /export type TelemetryPolicyState\s*=\s*([^;]+);/ +); +if (!unionMatch) { + console.error( + "ERROR: could not find `export type TelemetryPolicyState` in telemetry.ts" + ); + process.exit(1); +} +const tsStates = new Set(); +for (const m of unionMatch[1].matchAll(/'([a-z-]+)'/g)) { + tsStates.add(m[1]); +} + +// --- Compare --------------------------------------------------------------- +for (const [wire, variant] of rustStates) { + if (!csharpStates.has(wire)) { + errors.push( + `C# ParsePolicyState does not handle '${wire}' (Rust PolicyState::${variant})` + ); + } + if (!tsStates.has(wire)) { + errors.push( + `TypeScript TelemetryPolicyState union is missing '${wire}' (Rust PolicyState::${variant})` + ); + } +} +for (const wire of csharpStates) { + if (!rustStates.has(wire)) { + errors.push( + `C# ParsePolicyState handles '${wire}' with no matching Rust PolicyState variant` + ); + } +} +for (const wire of tsStates) { + if (!rustStates.has(wire)) { + errors.push( + `TypeScript TelemetryPolicyState has '${wire}' with no matching Rust PolicyState variant` + ); + } +} + +if (errors.length > 0) { + console.error("ERROR: telemetry policy-state parity check failed:"); + for (const e of errors) { + console.error(` - ${e}`); + } + console.error( + "\nThe Rust `PolicyState::as_str()` in src/core/wxc_common/src/telemetry/policy.rs " + + "is the source of truth. Update the C# and TypeScript bindings to match." + ); + process.exit(1); +} + +console.log( + `Telemetry policy parity OK: ${rustStates.size} states match across Rust, C#, and TypeScript ` + + `(${[...rustStates.keys()].sort().join(", ")})` +); diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs new file mode 100644 index 000000000..ad9a2e4db --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs @@ -0,0 +1,453 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Mxc.Sdk; +using Xunit; + +namespace Microsoft.Mxc.Sdk.Tests; + +/// +/// Redirects the debug-build-only MXC_TEST_LOCALAPPDATA_OVERRIDE +/// environment variable (read by wxc_common::telemetry::consent in +/// place of the real LOCALAPPDATA — see that module for the security +/// rationale; a release-profile native build compiles this override out +/// entirely and always resolves the real per-user known-folder path) to a +/// fresh temp directory for the lifetime of the fixture, so these tests +/// never touch the real developer/CI-machine telemetry consent file, and +/// restores the original value on dispose. xUnit runs the [Fact]s within a +/// single class sequentially by default, so no additional locking is needed +/// here. The constructor verifies the redirect took effect and fails +/// the fixture loudly if it did not (see AssertStoreIsRedirected), +/// rather than silently operating on the real per-user store when the native +/// library under test happens to be a release build. +/// +public sealed class MxcTelemetryTests : IDisposable +{ + private const string OverrideEnvVar = "MXC_TEST_LOCALAPPDATA_OVERRIDE"; + private const string PolicyOverrideEnvVar = "MXC_TEST_POLICY_KEY_OVERRIDE"; + private readonly string? _originalOverride; + private readonly string? _originalPolicyOverride; + private readonly string _tempDir; + private readonly string _policySubkey; + + public MxcTelemetryTests() + { + _originalOverride = Environment.GetEnvironmentVariable(OverrideEnvVar); + _tempDir = Path.Combine(Path.GetTempPath(), $"mxc_dotnet_consent_test_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + Environment.SetEnvironmentVariable(OverrideEnvVar, _tempDir); + + // Redirect the administrative policy read to a throwaway HKCU key too, + // so these tests are unaffected by a real MXC telemetry policy on the + // build machine and can exercise the policy path without elevation. + _originalPolicyOverride = Environment.GetEnvironmentVariable(PolicyOverrideEnvVar); + _policySubkey = $@"Software\MxcTelemetryPolicyDotnetTest\{Guid.NewGuid():N}"; + if (OperatingSystem.IsWindows()) + { + Microsoft.Win32.Registry.CurrentUser.CreateSubKey(_policySubkey)?.Dispose(); + } + Environment.SetEnvironmentVariable(PolicyOverrideEnvVar, _policySubkey); + + AssertStoreIsRedirected(); + AssertPolicyKeyIsRedirected(); + } + + /// + /// The policy counterpart to : prove + /// the native library honours MXC_TEST_POLICY_KEY_OVERRIDE before + /// any policy test runs. A release-profile mxc_ffi compiles the + /// override out and would silently read the machine's real + /// HKLM\SOFTWARE\Policies\Mxc key instead, making every + /// policy assertion below meaningless — passing or failing on the build + /// agent's administrative state rather than on the code under test. + /// + /// Two probes are used for the same reason as the consent check: a single + /// one could coincidentally match the real machine policy, but the real + /// policy cannot be both Blocked and Allowed. + /// + private void AssertPolicyKeyIsRedirected() + { + if (!OperatingSystem.IsWindows()) + { + // Off Windows GetPolicy short-circuits to NotApplicable without + // reading any registry, so there is nothing to redirect. + return; + } + + foreach (var (value, expected) in new[] + { + (0, TelemetryPolicyState.Blocked), + (3, TelemetryPolicyState.Allowed), + }) + { + SetPolicyValue(value); + var observed = MxcTelemetry.GetPolicy(); + if (observed != expected) + { + throw new InvalidOperationException( + $"telemetry policy key is NOT redirected to 'HKCU\\{_policySubkey}': wrote " + + $"AllowTelemetry={value} there but GetPolicy() returned {observed}. These tests " + + "refuse to run against the real machine policy. The native mxc_ffi library under " + + "test is most likely a release build, which compiles out the " + + PolicyOverrideEnvVar + " override. Rebuild it with " + + "`cargo build -p mxc_ffi --features dotnetsdk` (debug) and re-run."); + } + } + + // Leave the fixture in the unmanaged default state. + SetPolicyValue(null); + } + + /// + /// Writes the administrative AllowTelemetry policy value into the + /// redirected key, or removes it when is null. + /// + private void SetPolicyValue(int? value) + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + using var key = Microsoft.Win32.Registry.CurrentUser.CreateSubKey(_policySubkey)!; + if (value is null) + { + key.DeleteValue("AllowTelemetry", throwOnMissingValue: false); + } + else + { + key.SetValue("AllowTelemetry", value.Value, Microsoft.Win32.RegistryValueKind.DWord); + } + } + + /// + /// Writes AllowTelemetry as a REG_SZ rather than a + /// REG_DWORD — the mistake an administrator makes by typing the + /// value in by hand. + /// + private void SetPolicyStringValue(string value) + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + using var key = Microsoft.Win32.Registry.CurrentUser.CreateSubKey(_policySubkey)!; + key.SetValue("AllowTelemetry", value, Microsoft.Win32.RegistryValueKind.String); + } + + /// + /// Prove — without writing anything through the SDK — that the native + /// library under test actually honours MXC_TEST_LOCALAPPDATA_OVERRIDE. + /// A release-profile mxc_ffi compiles the override out, in which + /// case every test below would silently read and (worse) overwrite the + /// real per-user consent file. Two probes are used because a single one + /// could coincidentally match the real store's state; the real store + /// cannot be both granted and denied, so two matching reads prove the + /// redirect. This is read-only against the SDK: the records are written + /// directly to the redirected path by this test. + /// + private void AssertStoreIsRedirected() + { + if (!OperatingSystem.IsWindows()) + { + // Off Windows GetConsent short-circuits to NotApplicable without + // touching any store, so there is nothing to redirect. + return; + } + + foreach (var (value, expected) in new[] + { + ("granted", TelemetryConsentState.Granted), + ("denied", TelemetryConsentState.Denied), + }) + { + WriteConsentRecord(value); + var observed = MxcTelemetry.GetConsent(); + if (observed != expected) + { + throw new InvalidOperationException( + $"telemetry consent store is NOT redirected to '{_tempDir}': wrote '{value}' " + + $"there but GetConsent() returned {observed}. These tests refuse to run against " + + "the real per-user consent file. The native mxc_ffi library under test is most " + + "likely a release build, which compiles out the " + OverrideEnvVar + " override. " + + "Rebuild it with `cargo build -p mxc_ffi --features dotnetsdk` (debug) and re-run."); + } + } + + File.Delete(ConsentFilePath()); + } + + private string ConsentFilePath() => Path.Combine(_tempDir, "mxc", "telemetry-consent.json"); + + private void WriteConsentRecord(string consent) + { + var path = ConsentFilePath(); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText( + path, + $$"""{"schemaVersion":1,"consent":"{{consent}}","source":"test","promptedMxcVersion":"0.0.0","updatedAtEpoch":0}"""); + } + + public void Dispose() + { + Environment.SetEnvironmentVariable(OverrideEnvVar, _originalOverride); + Environment.SetEnvironmentVariable(PolicyOverrideEnvVar, _originalPolicyOverride); + if (OperatingSystem.IsWindows()) + { + try + { + Microsoft.Win32.Registry.CurrentUser.DeleteSubKeyTree(_policySubkey, throwOnMissingSubKey: false); + } + catch (UnauthorizedAccessException) + { + // Best-effort cleanup; not worth failing the test run over. + } + } + try + { + Directory.Delete(_tempDir, recursive: true); + } + catch (IOException) + { + // Best-effort cleanup; not worth failing the test run over. + } + } + + [Fact] + public void GetConsent_FreshStore_ReportsUndeterminedOnWindowsOrNotApplicableElsewhere() + { + var state = MxcTelemetry.GetConsent(); + var expected = OperatingSystem.IsWindows() + ? TelemetryConsentState.Undetermined + : TelemetryConsentState.NotApplicable; + Assert.Equal(expected, state); + } + + [Fact] + public void SetConsent_ThenGetConsent_RoundTrips_OnWindows() + { + if (!OperatingSystem.IsWindows()) + { + // MXC must not gather telemetry off Windows, so consent cannot be + // persisted there either — see SetConsent_NonWindows_ThrowsConsentWriteFailed. + return; + } + + MxcTelemetry.SetConsent(true, "prompt"); + Assert.Equal(TelemetryConsentState.Granted, MxcTelemetry.GetConsent()); + + MxcTelemetry.SetConsent(false, "settings-ui"); + Assert.Equal(TelemetryConsentState.Denied, MxcTelemetry.GetConsent()); + } + + [Fact] + public void NeedsConsentPrompt_FreshStore_IsTrueOnWindowsAndFalseElsewhere() + { + // Off Windows MXC collects nothing, so a host must never be told to + // ask — prompting there would be a privacy defect, not just noise. + Assert.Equal(OperatingSystem.IsWindows(), MxcTelemetry.NeedsConsentPrompt()); + } + + [Fact] + public void NeedsConsentPrompt_AfterAnyDecision_IsFalse_OnWindows() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + MxcTelemetry.SetConsent(false, "prompt"); + Assert.False(MxcTelemetry.NeedsConsentPrompt()); + + MxcTelemetry.SetConsent(true, "settings-ui"); + Assert.False(MxcTelemetry.NeedsConsentPrompt()); + } + + [Fact] + public void SetConsent_NonWindows_ThrowsConsentWriteFailed() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + var ex = Assert.Throws(() => MxcTelemetry.SetConsent(true)); + Assert.Equal(ErrorCode.ConsentWriteFailed, ex.Code); + } + + [Fact] + public void GetPolicy_NoPolicyConfigured_IsUnrestrictedOnWindowsAndNotApplicableElsewhere() + { + SetPolicyValue(null); + var expected = OperatingSystem.IsWindows() + ? TelemetryPolicyState.Unrestricted + : TelemetryPolicyState.NotApplicable; + Assert.Equal(expected, MxcTelemetry.GetPolicy()); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(42)] + [InlineData(-1)] + public void GetPolicy_AnyValueOtherThanOptional_IsBlocked_OnWindows(int value) + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + // MXC's data is product-and-service-usage (optional) diagnostic data, + // so only level 3 permits it. Unrecognised values fail closed rather + // than being treated as "no policy". + SetPolicyValue(value); + Assert.Equal(TelemetryPolicyState.Blocked, MxcTelemetry.GetPolicy()); + } + + [Fact] + public void GetPolicy_Optional_IsAllowed_OnWindows() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + SetPolicyValue(3); + Assert.Equal(TelemetryPolicyState.Allowed, MxcTelemetry.GetPolicy()); + } + + /// + /// An administrator who sets AllowTelemetry as a string instead of a + /// DWORD has still expressed an intent to manage this machine. The value + /// cannot be evaluated, so it must fail closed to Blocked — never be read + /// as an unmanaged machine, which would let a prior consent grant + /// re-enable the collection the administrator meant to stop. + /// + [Theory] + [InlineData("0")] + [InlineData("3")] + [InlineData("")] + [InlineData("not-a-number")] + public void GetPolicy_WrongValueType_IsBlockedNotUnrestricted_OnWindows(string value) + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + SetPolicyStringValue(value); + Assert.Equal(TelemetryPolicyState.Blocked, MxcTelemetry.GetPolicy()); + } + + [Fact] + public void GetPolicy_IsNeverAGrant_ConsentIsStillRequired_OnWindows() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + // An administrator permitting telemetry must not stand in for the + // user's own decision: the prompt is still owed. + SetPolicyValue(3); + Assert.Equal(TelemetryConsentState.Undetermined, MxcTelemetry.GetConsent()); + Assert.True(MxcTelemetry.NeedsConsentPrompt()); + } + + [Fact] + public void GetPolicy_BlockedSuppressesTheConsentPrompt_OnWindows() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + // There is no point asking a user for permission an administrator has + // already refused, but the recorded consent state is left untouched so + // relaxing the policy later restores the user's real choice. + SetPolicyValue(0); + Assert.False(MxcTelemetry.NeedsConsentPrompt()); + Assert.Equal(TelemetryConsentState.Undetermined, MxcTelemetry.GetConsent()); + + MxcTelemetry.SetConsent(true, "settings-ui"); + Assert.Equal(TelemetryConsentState.Granted, MxcTelemetry.GetConsent()); + Assert.Equal(TelemetryPolicyState.Blocked, MxcTelemetry.GetPolicy()); + + SetPolicyValue(null); + Assert.Equal(TelemetryPolicyState.Unrestricted, MxcTelemetry.GetPolicy()); + Assert.Equal(TelemetryConsentState.Granted, MxcTelemetry.GetConsent()); + } + + [Fact] + public void SetConsent_NullSource_DefaultsToSdkAndDoesNotThrowOnWindows() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + // Should not throw; "sdk" is the documented default provenance. + MxcTelemetry.SetConsent(true); + Assert.Equal(TelemetryConsentState.Granted, MxcTelemetry.GetConsent()); + } + + [Theory] + [InlineData(typeof(DllNotFoundException))] + [InlineData(typeof(EntryPointNotFoundException))] + [InlineData(typeof(TypeInitializationException))] + [InlineData(typeof(BadImageFormatException))] + public void NativeLoadFailures_AreTreatedAsFailClosed(Type exceptionType) + { + // These are the failures GetConsent must swallow into Undetermined + // rather than throwing out of a read-only status query. + // EntryPointNotFoundException in particular covers an mxc_ffi that + // loads but predates the consent entry points. + var ex = exceptionType == typeof(TypeInitializationException) + ? new TypeInitializationException("Microsoft.Mxc.Sdk.Native.NativeMethods", null) + : (Exception)Activator.CreateInstance(exceptionType)!; + Assert.True(MxcTelemetry.IsNativeLoadFailure(ex)); + } + + [Fact] + public void GenuineNativeFailures_AreNotSwallowed() + { + // A real failure reported by the native layer must not be + // misclassified as "library missing" and silently downgraded. + Assert.False(MxcTelemetry.IsNativeLoadFailure( + new MxcException(ErrorCode.ConsentWriteFailed, "boom"))); + Assert.False(MxcTelemetry.IsNativeLoadFailure(new InvalidOperationException())); + } + + [Fact] + public void ReadOnlyQueries_NeverThrow() + { + // Both are documented as "always succeeds". NeedsConsentPrompt used to + // throw MxcException whenever the native layer reported a non-Success + // status — which includes a caught panic — despite that contract, so a + // host calling it on its startup path could be taken down by an + // unreadable consent store. + var prompt = Record.Exception(() => MxcTelemetry.NeedsConsentPrompt()); + Assert.Null(prompt); + + var policy = Record.Exception(() => MxcTelemetry.GetPolicy()); + Assert.Null(policy); + } + + [Fact] + public void MxcException_PreservesTheUnderlyingCause() + { + // The read/write paths convert unexpected exceptions to MxcException so + // a raw type cannot escape a documented contract; that conversion must + // not lose the original, which is the only thing that explains why a + // broken install failed. + var cause = new DllNotFoundException("mxc_ffi not found"); + var ex = new MxcException(ErrorCode.ConsentWriteFailed, "could not persist", cause); + + Assert.Same(cause, ex.InnerException); + Assert.Equal(ErrorCode.ConsentWriteFailed, ex.Code); + Assert.Contains("could not persist", ex.ToString(), StringComparison.Ordinal); + Assert.Contains("mxc_ffi not found", ex.ToString(), StringComparison.Ordinal); + } +} diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/ErrorCode.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/ErrorCode.cs index 6bbde7798..1137ecdcc 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/ErrorCode.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/ErrorCode.cs @@ -58,4 +58,12 @@ public enum ErrorCode /// The native side panicked and was caught at the boundary (FFI-local). Panic = 102, + + /// + /// Telemetry consent could not be persisted, e.g. on a non-Windows host, or + /// because %LOCALAPPDATA% is unavailable/unwritable (FFI-local). MXC only + /// collects telemetry on Windows and only with persisted, explicit consent; + /// see docs/telemetry/telemetry-consent-design.md. + /// + ConsentWriteFailed = 103, } diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/Microsoft.Mxc.Sdk.csproj b/sdk/dotnet/Microsoft.Mxc.Sdk/Microsoft.Mxc.Sdk.csproj index c33b1fd7f..9d7b0ea36 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/Microsoft.Mxc.Sdk.csproj +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/Microsoft.Mxc.Sdk.csproj @@ -23,6 +23,11 @@ $(MSBuildProjectDirectory)/../../../src $(MSBuildProjectDirectory)/Native/NativeMethods.g.cs + + --release @@ -38,12 +43,22 @@ BeforeTargets="CoreCompile" Inputs="$(MxcSrcDir)/ffi/mxc_ffi/src/lib.rs;$(MxcSrcDir)/ffi/mxc_ffi/src/streaming.rs;$(MxcSrcDir)/ffi/mxc_ffi/src/state_aware.rs;$(MxcSrcDir)/ffi/mxc_ffi/build.rs" Outputs="$(MxcGeneratedBindings)"> - - + + + + + + + {InnerException}"; } diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs new file mode 100644 index 000000000..f36e5bacb --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs @@ -0,0 +1,398 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Runtime.InteropServices; +using Microsoft.Mxc.Sdk.Native; + +namespace Microsoft.Mxc.Sdk; + +/// +/// Administers MXC's persisted, MXC-owned telemetry consent flag. See +/// docs/telemetry/telemetry-consent-design.md for the full design. +/// +/// This is deliberately UI-agnostic: it does not render a consent prompt. +/// A hosting application should call once +/// (e.g. before its first sandbox run) and, if it returns true, show +/// its own consent UI and then call with the user's +/// choice. A settings page can call and +/// at any later time to let the user change their +/// mind. +/// +/// Prefer over testing +/// against +/// : only the former also +/// accounts for administrative policy. Under a blocking policy the stored +/// consent is still , so +/// branching on alone would show a prompt that +/// cannot take effect. +/// +public static class MxcTelemetry +{ + static MxcTelemetry() + { + NativeLibraryResolver.Initialize(); + } + + /// + /// Read the persisted telemetry consent state. Never throws for "no + /// decision yet" or "not on Windows"; both are ordinary return values + /// ( and + /// respectively). A + /// missing or unloadable native library also fails closed to + /// rather than throwing. + /// + /// + /// The native call returned a non-success status — an FFI-local fault + /// (null out-pointer, caught panic), not an ordinary consent outcome. + /// + public static TelemetryConsentState GetConsent() + { + // Windows-only by design: MXC never collects telemetry on other + // platforms, so there is nothing to consent to. This guard must come + // first — without it, the catch below would report Undetermined on a + // macOS/Linux host with no native library, and a host application + // that prompts on Undetermined would show a telemetry consent prompt + // on a platform where MXC collects nothing. + if (!OperatingSystem.IsWindows()) + { + return TelemetryConsentState.NotApplicable; + } + + try + { + unsafe + { + byte* outUtf8 = null; + var status = NativeMethods.mxc_telemetry_get_consent(&outUtf8); + try + { + if (status != (int)ErrorCode.Success) + { + // mxc_telemetry_get_consent only ever fails for FFI-local + // reasons (null out-pointer, caught panic); it never fails + // because of platform or missing consent. + throw new MxcException((ErrorCode)status, "failed to read telemetry consent state"); + } + + var value = outUtf8 is null ? null : Marshal.PtrToStringUTF8((IntPtr)outUtf8); + return ParseConsentState(value); + } + finally + { + if (outUtf8 is not null) + { + NativeMethods.mxc_string_free(outUtf8); + } + } + } + } + catch (Exception ex) when (IsNativeLoadFailure(ex)) + { + // The native mxc_ffi library is missing, mismatched, or failed to + // load (e.g. running on a fresh/broken install). GetConsent must + // not throw for this — treat it the same as "no decision yet". + ReportFailClosed("GetConsent", "Undetermined", ex); + return TelemetryConsentState.Undetermined; + } + catch (Exception ex) when (ex is not MxcException) + { + // Anything unexpected from the marshalling layer is wrapped rather + // than allowed to escape raw: this method documents MxcException as + // its only failure mode, and a host that catches that per the + // contract would otherwise still be taken down by a surprise type. + ReportFailClosed("GetConsent", "MxcException", ex); + throw new MxcException(ErrorCode.BackendError, "failed to read telemetry consent state", ex); + } + } + + /// + /// Whether an exception means "the native mxc_ffi library could not be + /// loaded or does not export what we need", as opposed to a genuine + /// failure from inside the native call. + /// is included because an older + /// mxc_ffi that predates the consent entry points loads fine and only + /// fails at the call — which must fail closed the same way a missing DLL + /// does, not throw out of a read-only status query. + /// + internal static bool IsNativeLoadFailure(Exception ex) => + ex is DllNotFoundException + or EntryPointNotFoundException + or TypeInitializationException + or BadImageFormatException; + + private static readonly HashSet ReportedFailures = new(StringComparer.Ordinal); + + /// + /// Report a failure that was swallowed to keep a privacy gate fail-closed. + /// + /// These paths deliberately return a safe value instead of throwing, which + /// would otherwise make a broken install completely silent and + /// undiagnosable. Reported once per distinct failure per process: a host + /// may poll these getters (e.g. to render a settings toggle), and a warning + /// on every call would be noise rather than signal. + /// + /// Never throws: it is called from catch blocks whose whole purpose + /// is to guarantee the caller cannot crash, so an exception escaping here + /// would defeat the thing it exists to support. + /// + private static void ReportFailClosed(string operation, string safeResult, object detail) + { + try + { + var message = + $"mxc: {operation} failed and is reporting '{safeResult}' to stay fail-closed: {detail}"; + + lock (ReportedFailures) + { + if (!ReportedFailures.Add(message)) + { + return; + } + } + + Console.Error.WriteLine(message); + } + catch + { + // Diagnostics must never be able to break the caller. + } + } + + /// + /// Whether the hosting application should show its own first-run telemetry + /// consent prompt: only on Windows, when no decision + /// has been recorded yet. + /// + /// The policy behind this answer lives in Rust + /// (ConsentState::needs_prompt) and is shared with the Node SDK, the + /// Rust SDK, and the wxc-exec CLI — it is deliberately not + /// re-derived here from , so the definition of + /// "should we ask?" cannot drift between language bindings. + /// + /// Always succeeds. Fails closed to (do not prompt) + /// on any failure — the native library cannot be reached, the native call + /// reports an error status, or it panics: prompting would be pointless + /// there, since could not persist the answer + /// either. A read-only status query on a privacy gate must never be able to + /// crash the host, so nothing is allowed to propagate out of this method. + /// + public static bool NeedsConsentPrompt() + { + if (!OperatingSystem.IsWindows()) + { + return false; + } + + try + { + unsafe + { + int needsPrompt = 0; + var status = NativeMethods.mxc_telemetry_needs_consent_prompt(&needsPrompt); + if (status != (int)ErrorCode.Success) + { + // Fail closed rather than throw. The native layer reports a + // non-Success status for FFI-local reasons including a + // caught panic, and this method is documented never to + // throw — a host calling it on its startup path must not be + // brought down by an unreadable consent store. + ReportFailClosed("NeedsConsentPrompt", "false", (ErrorCode)status); + return false; + } + + return needsPrompt != 0; + } + } + catch (Exception ex) + { + ReportFailClosed("NeedsConsentPrompt", "false", ex); + return false; + } + } + + /// + /// Read the administrative (MDM / Group Policy) telemetry policy for this + /// machine. + /// + /// The policy is a ceiling, never a grant: + /// does not mean telemetry is + /// on, only that an administrator has not forbidden it — an explicit user + /// consent grant is still required. + /// means nothing is collected regardless of consent, and + /// already returns + /// in that case. + /// + /// Exposed so a host can distinguish "the user has not opted in" from + /// "telemetry is unavailable on this device" and explain the difference, + /// rather than rendering a toggle that silently does nothing. + /// + /// Always succeeds. Fails closed to + /// on any failure — the native library cannot be reached, the native call + /// reports an error status, or the returned state string is unrecognized — + /// since nothing can be collected in that state either. A read-only status + /// query on a privacy gate must never be able to crash the host. + /// + /// This is deliberately stricter than , which still + /// throws for a genuine native-layer failure. + /// Consent has two meaningfully different unknowns a caller must be able to + /// tell apart — "the user has not decided yet" versus "we could not read the + /// decision" — so collapsing the latter into + /// would make a host prompt + /// on a broken install. The policy ceiling has no such distinction: + /// is simultaneously the "unknown" + /// answer and the safe one, so there is nothing to lose by returning it. + /// + public static TelemetryPolicyState GetPolicy() + { + if (!OperatingSystem.IsWindows()) + { + return TelemetryPolicyState.NotApplicable; + } + + try + { + unsafe + { + byte* outUtf8 = null; + var status = NativeMethods.mxc_telemetry_get_policy(&outUtf8); + try + { + if (status != (int)ErrorCode.Success) + { + // Fail closed rather than throw: this method is + // documented never to throw, and a privacy gate that + // cannot be read must deny, not crash the host. + ReportFailClosed("GetPolicy", "Blocked", (ErrorCode)status); + return TelemetryPolicyState.Blocked; + } + + var value = outUtf8 is null ? null : Marshal.PtrToStringUTF8((IntPtr)outUtf8); + return ParsePolicyState(value); + } + finally + { + if (outUtf8 is not null) + { + NativeMethods.mxc_string_free(outUtf8); + } + } + } + } + catch (Exception ex) + { + // Catch-all, not just native-load failures: this method is + // documented never to throw, so anything unexpected from the + // marshalling layer must fail closed too rather than reach the host. + ReportFailClosed("GetPolicy", "Blocked", ex); + return TelemetryPolicyState.Blocked; + } + } + + /// + /// Grant or revoke telemetry consent and persist the decision. + /// + /// to grant, to revoke/deny. + /// + /// Optional, free-form provenance for support/debugging (e.g. "prompt", + /// "settings-ui"). Never transmitted anywhere. Defaults to "sdk". + /// + /// + /// The decision could not be persisted — always the case on non-Windows + /// hosts (), since MXC must not + /// collect, and therefore must not offer consent for, telemetry there. + /// + public static void SetConsent(bool granted, string? source = null) + { + // Windows-only by design; fail here rather than depending on the + // native layer to refuse, so the contract holds even when mxc_ffi is + // missing entirely. + if (!OperatingSystem.IsWindows()) + { + throw new MxcException( + ErrorCode.ConsentWriteFailed, + "telemetry consent could not be persisted (MXC only collects telemetry, and only offers consent, on Windows)"); + } + + var sourceBuf = ToNullTerminatedUtf8(source ?? "sdk"); + + try + { + unsafe + { + fixed (byte* sourcePtr = sourceBuf) + { + var status = NativeMethods.mxc_telemetry_set_consent(granted ? 1 : 0, sourcePtr); + if (status != (int)ErrorCode.Success) + { + throw new MxcException( + (ErrorCode)status, + status == (int)ErrorCode.ConsentWriteFailed + ? "telemetry consent could not be persisted (MXC only collects telemetry, and only offers consent, on Windows)" + : "failed to persist telemetry consent"); + } + } + } + } + catch (Exception ex) when (ex is not MxcException) + { + // A broken install throws DllNotFoundException (and friends) from + // the marshalling layer. This method documents MxcException as its + // only failure mode, so convert rather than leak a raw type a host + // following the contract would not be catching. Unlike the read + // paths this still throws: the caller asked us to persist a + // decision and it did not happen, so silence would be a lie. + ReportFailClosed("SetConsent", "MxcException", ex); + throw new MxcException( + ErrorCode.ConsentWriteFailed, + "telemetry consent could not be persisted (the MXC native library could not be reached)", + ex); + } + } + + /// + /// Maps the native consent string. An unrecognised value (including + /// ) falls through to + /// — never a state that + /// would let collection proceed — and is reported, so a native/binding + /// version skew is diagnosable instead of silently reading as "no + /// decision yet". + /// + private static TelemetryConsentState ParseConsentState(string? value) => value switch + { + "granted" => TelemetryConsentState.Granted, + "denied" => TelemetryConsentState.Denied, + "undetermined" => TelemetryConsentState.Undetermined, + "not-applicable" => TelemetryConsentState.NotApplicable, + _ => UnrecognizedConsentState(value), + }; + + private static TelemetryConsentState UnrecognizedConsentState(string? value) + { + ReportFailClosed("GetConsent", "Undetermined", $"unrecognized native consent state '{value ?? ""}'"); + return TelemetryConsentState.Undetermined; + } + + /// + /// Maps the native policy string. Unknown values fall through to + /// rather than + /// Unrestricted: a binding that cannot understand the native + /// answer must not report the permissive one. The mismatch is reported + /// so the skew is diagnosable. + /// + private static TelemetryPolicyState ParsePolicyState(string? value) => value switch + { + "unrestricted" => TelemetryPolicyState.Unrestricted, + "allowed" => TelemetryPolicyState.Allowed, + "blocked" => TelemetryPolicyState.Blocked, + "not-applicable" => TelemetryPolicyState.NotApplicable, + _ => UnrecognizedPolicyState(value), + }; + + private static TelemetryPolicyState UnrecognizedPolicyState(string? value) + { + ReportFailClosed("GetPolicy", "Blocked", $"unrecognized native policy state '{value ?? ""}'"); + return TelemetryPolicyState.Blocked; + } + + private static byte[] ToNullTerminatedUtf8(string value) => System.Text.Encoding.UTF8.GetBytes(value + "\0"); +} diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/Native/NativeLibraryResolver.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/Native/NativeLibraryResolver.cs index dfb339a37..524149cd8 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/Native/NativeLibraryResolver.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/Native/NativeLibraryResolver.cs @@ -20,8 +20,24 @@ internal static class NativeLibraryResolver /// /// Register the resolver once. Called from the static constructor of the - /// SDK's public entry point so it runs before the first P/Invoke. + /// SDK's public entry points so it runs before the first P/Invoke. /// + /// + /// Never throws. throws if + /// a resolver is already registered for this assembly (for example, when a + /// host application registers its own). Because this runs from a static + /// constructor, letting that escape would raise + /// TypeInitializationException on *every* subsequent member access + /// and permanently poison the type — breaking the never-throw contract of + /// members such as MxcTelemetry.NeedsConsentPrompt and + /// MxcTelemetry.GetPolicy. + /// + /// Swallowing is also the semantically correct outcome: this resolver only + /// *adds* dev/test search paths. If registration fails, the default loader + /// (and any resolver the host registered) still resolves the library, and a + /// genuinely missing library is already handled fail-closed at each call + /// site. + /// internal static void Initialize() { if (Interlocked.Exchange(ref _initialized, 1) != 0) @@ -29,7 +45,29 @@ internal static void Initialize() return; } - NativeLibrary.SetDllImportResolver(typeof(NativeLibraryResolver).Assembly, Resolve); + try + { + NativeLibrary.SetDllImportResolver(typeof(NativeLibraryResolver).Assembly, Resolve); + } + catch (Exception ex) + { + // Deliberately not rethrown; see the remarks above. Reported once + // (the _initialized latch above guarantees single entry) so the + // failure is diagnosable rather than silent. The report is itself + // best-effort: a host may have replaced or closed Console.Error, + // and letting *that* throw would re-poison the static ctor this + // catch exists to protect. + try + { + Console.Error.WriteLine( + $"mxc: could not register the native library resolver ({ex.GetType().Name}: {ex.Message}). " + + "Falling back to the default loader."); + } + catch + { + // Nothing left to report with. Swallow. + } + } } private static IntPtr Resolve(string libraryName, Assembly assembly, DllImportSearchPath? searchPath) @@ -65,11 +103,23 @@ private static IEnumerable CandidatePaths() yield return Path.Combine(baseDir, file); yield return Path.Combine(baseDir, "runtimes", RuntimeInformation.RuntimeIdentifier, "native", file); - // Dev layout: walk up looking for the Cargo target dir. + // Dev layout: walk up looking for the Cargo target dir. Probe the + // Cargo profile matching this assembly's build configuration first: + // a Release C# build binding against a stale debug mxc_ffi would pick + // up debug-only behaviour (e.g. the LOCALAPPDATA consent-store + // override), which is exactly what a Release build must not do. for (var dir = new DirectoryInfo(baseDir); dir is not null; dir = dir.Parent) { +#if DEBUG yield return Path.Combine(dir.FullName, "src", "target", "debug", file); yield return Path.Combine(dir.FullName, "src", "target", "release", file); +#else + // No debug fallback here on purpose: silently binding a Release + // build to a debug mxc_ffi would re-enable the debug-only + // overrides. The csproj builds the release native library for a + // Release configuration, so this path exists when it is needed. + yield return Path.Combine(dir.FullName, "src", "target", "release", file); +#endif } } diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/TelemetryConsentState.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/TelemetryConsentState.cs new file mode 100644 index 000000000..17536c5f7 --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/TelemetryConsentState.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Mxc.Sdk; + +/// +/// The user's persisted telemetry consent decision. See +/// docs/telemetry/telemetry-consent-design.md for the full design: MXC only +/// ever collects telemetry on Windows, and only when this flag is +/// . It is stored per-user by MXC itself +/// (%LOCALAPPDATA%\mxc\telemetry-consent.json) and is never derived from, or +/// synchronized with, any Windows-level diagnostics/consent setting. +/// +public enum TelemetryConsentState +{ + /// The user has explicitly agreed to telemetry collection. + Granted, + + /// The user has explicitly declined telemetry collection. + Denied, + + /// + /// No decision has been recorded yet (fresh install, or an unreadable/corrupt + /// store). Treated identically to for gating purposes — + /// callers should use this state to decide whether to show a first-run + /// consent prompt. + /// + Undetermined, + + /// + /// Not a Windows host. MXC does not collect telemetry here, so consent is not + /// a meaningful concept — hosts must not offer a consent prompt at all on + /// these platforms. + /// + NotApplicable, +} diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/TelemetryPolicyState.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/TelemetryPolicyState.cs new file mode 100644 index 000000000..b648b541c --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/TelemetryPolicyState.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Mxc.Sdk; + +/// +/// The administrative (MDM / Group Policy) telemetry decision for this machine. +/// See docs/telemetry/telemetry-policy.md for the admin-facing reference. +/// +/// An administrator can disable MXC telemetry machine-wide via Intune, another +/// MDM, or Group Policy. The policy is a ceiling, never a grant: an +/// administrator who permits telemetry has not consented on the user's behalf, +/// so an explicit is still +/// required before anything is collected. +/// +/// MXC deliberately does not read the Windows-wide diagnostic data setting: +/// Microsoft's Policy CSP documentation scopes that policy to Windows itself +/// and states it does not apply to additional installed apps, and the Windows +/// privacy guidance for app-classified components requires them to own their +/// own notice and consent experience. +/// +public enum TelemetryPolicyState +{ + /// + /// No administrative policy is configured. Telemetry is governed solely by + /// the user's own consent decision. This is not a grant. + /// + Unrestricted, + + /// + /// An administrator has permitted the optional (usage) telemetry category + /// MXC emits. Still requires user consent before anything is collected. + /// + Allowed, + + /// + /// An administrator has denied MXC telemetry, the configured policy value + /// could not be understood, or the policy could not be determined at all. + /// Nothing is collected regardless of user consent, and hosts must not + /// offer a consent prompt. + /// + /// Because this state also covers "could not be determined", a host should + /// word its UI as "telemetry is unavailable on this device" rather than + /// asserting that an administrator is responsible. + /// + Blocked, + + /// + /// Not a Windows host. MXC collects no telemetry here, so administrative + /// policy is not a meaningful concept. + /// + NotApplicable, +} diff --git a/sdk/dotnet/README.md b/sdk/dotnet/README.md index dd9f58f9f..ccd365eda 100644 --- a/sdk/dotnet/README.md +++ b/sdk/dotnet/README.md @@ -1,6 +1,6 @@ # Microsoft.Mxc.Sdk (C# SDK) -A .NET binding for [MXC](../README.md) (Microsoft eXecution Container), +A .NET binding for [MXC](../../README.md) (Microsoft eXecution Container), implemented in C#. It runs a command inside a sandbox described by a `SandboxPolicy`, capturing the output — by P/Invoking the native `mxc_ffi` library, which wraps the Rust engine. @@ -73,6 +73,70 @@ whole tree and may be called from another thread while `WaitAsync` is in flight. distinctly (but cannot be interrupted by a concurrent `Kill`). Dispose the process to release native resources (killing the child if still running). +## Telemetry consent + +MXC only ever collects telemetry on Windows, and only after the end user has +explicitly opted in — a persisted, MXC-owned consent flag gates every +emission (never a Windows-level setting like Diagnostics & feedback). See +[`docs/telemetry/telemetry-consent-design.md`](../../docs/telemetry/telemetry-consent-design.md) +for the full design. + +`MxcTelemetry` is UI-agnostic: call it once at first run, and again at any +later time from your own settings surface. + +```csharp +using Microsoft.Mxc.Sdk; + +if (MxcTelemetry.NeedsConsentPrompt()) +{ + // Show your own consent UI, then record the user's choice: + MxcTelemetry.SetConsent(userOptedIn, "prompt"); +} + +// Anywhere later, e.g. a settings toggle: +var state = MxcTelemetry.GetConsent(); +MxcTelemetry.SetConsent(false, "settings-ui"); +``` + +On non-Windows hosts `NeedsConsentPrompt()` always returns `false`, `GetConsent()` always returns +`TelemetryConsentState.NotApplicable` and `SetConsent(...)` always throws +`MxcException` with `Code == ErrorCode.ConsentWriteFailed` — MXC neither +collects nor offers consent for telemetry off Windows. Both check +`OperatingSystem.IsWindows()` before touching the native library, so the +guarantee holds even when `mxc_ffi` is missing entirely. + +### Administrative policy + +An IT administrator can block MXC telemetry device-wide via MXC's own +Group Policy / MDM setting. `MxcTelemetry.GetPolicy()` reports the result: + +```csharp +if (MxcTelemetry.GetPolicy() == TelemetryPolicyState.Blocked) +{ + // Don't show a consent toggle; telemetry is unavailable on this device. +} +``` + +Two things worth designing around: + +- The policy is a **ceiling, never a grant**. `Allowed` does not mean + telemetry is on — the user must still consent. Only + `TelemetryConsentState.Granted` *and* a non-blocking policy result in + collection. +- When the policy is `Blocked`, `NeedsConsentPrompt()` returns `false`, + because asking for permission an administrator has already refused is a + meaningless question. Word any UI as "telemetry is unavailable on this + device" rather than blaming the user's own choice. + +It never throws and fails closed: if the native library cannot be loaded, it +returns `Blocked`. On non-Windows hosts it is always `NotApplicable`. See +[`docs/telemetry/telemetry-policy.md`](../../docs/telemetry/telemetry-policy.md). + +`GetConsent()` never throws for a missing, mismatched, or outdated native +library: those fail closed to `TelemetryConsentState.Undetermined` (never +`Granted`). Only a genuine failure reported by the native layer surfaces as +`MxcException`. + ## Projects - **`Microsoft.Mxc.Sdk`** — the class library (public API + generated P/Invoke). @@ -81,6 +145,11 @@ process to release native resources (killing the child if still running). Build/test everything: `dotnet test sdk/dotnet/Microsoft.Mxc.Sdk.slnx`. +Run the tests in the **Debug** configuration. The telemetry-consent tests +redirect the consent store via `MXC_TEST_LOCALAPPDATA_OVERRIDE`, which the +native library compiles out in release builds; under `dotnet test -c Release` +they fail loudly rather than touch the real per-user consent file. + ## Native library loading `NativeLibraryResolver` locates `mxc_ffi` in this order: @@ -160,3 +229,4 @@ than linking third-party code directly against `mxc_ffi`. `scripts/check-dotnet-errorcode-parity.js` enforces that. - The C# package version tracks the Rust workspace version; `scripts/check-version-sync.js` enforces that. + diff --git a/sdk/node/README.md b/sdk/node/README.md index 4ec617dbe..7bfbc7ea3 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -383,6 +383,13 @@ getAvailableToolsPolicy(env?, options?) → FilesystemPolicyResult getUserProfilePolicy() → FilesystemPolicyResult getTemporaryFilesPolicy(env?) → FilesystemPolicyResult +// Telemetry consent (Windows-only; see Telemetry Consent section below) +getTelemetryConsent() → TelemetryConsentState +queryTelemetryConsent() → { state, needsPrompt, policy, error? } +needsTelemetryConsentPrompt() → boolean +setTelemetryConsent(granted, source?) +getTelemetryPolicy() → TelemetryPolicyState + // Capability types UiCapabilitySupport @@ -396,6 +403,89 @@ Full TypeScript definitions ship with the package (`dist/index.d.ts`). All expor --- +## Telemetry Consent + +MXC only ever collects telemetry on Windows, and only after the end user has +explicitly opted in — a persisted, MXC-owned consent flag gates every +emission (never a Windows-level setting like Diagnostics & feedback). See +[`docs/telemetry/telemetry-consent-design.md`](https://github.com/microsoft/mxc/blob/main/docs/telemetry/telemetry-consent-design.md) +for the full design. + +The SDK does not ship a consent UI — call these once at first run, and again +at any later time from your own settings surface: + +```typescript +import { needsTelemetryConsentPrompt, setTelemetryConsent, getTelemetryConsent } from '@microsoft/mxc-sdk'; + +if (needsTelemetryConsentPrompt()) { + // Show your own consent UI, then record the user's choice: + setTelemetryConsent(userOptedIn, 'prompt'); +} + +// Anywhere later, e.g. a settings toggle: +const state = getTelemetryConsent(); // 'granted' | 'denied' | 'undetermined' | 'not-applicable' +setTelemetryConsent(false, 'settings-toggle'); +``` + +On non-Windows hosts `getTelemetryConsent()` always returns `'not-applicable'` +and `setTelemetryConsent(...)` always throws — MXC neither collects nor offers +consent for telemetry off Windows. Both check `process.platform` before doing +anything else, so `needsTelemetryConsentPrompt()` is guaranteed `false` there; +you can call it unconditionally without special-casing the platform yourself. + +`getTelemetryConsent()` never throws: any failure to reach `wxc-exec` reads +back as `'undetermined'` (fail-closed — never `'granted'`). If you need to +tell a genuine "user has not decided yet" apart from a broken install, use +`queryTelemetryConsent()`, which returns the same state plus a diagnostic +`error` string when the state was forced by a failure: + +```typescript +const { state, needsPrompt, policy, error } = queryTelemetryConsent(); +if (error) { + console.warn(`mxc: could not read telemetry consent: ${error}`); +} +``` + +`needsPrompt` is the same answer `needsTelemetryConsentPrompt()` returns. It +is decided by the native layer (Rust `ConsentState::needs_prompt`) rather +than derived in each SDK, so the Node, C#, and Rust SDKs and the +`wxc-exec --telemetry-consent-status` CLI all agree by construction. If the +resolved `wxc-exec` is older than this SDK and does not report the field, +`needsPrompt` fails closed to `false` — MXC never prompts on a guess. + +### Administrative policy + +An IT administrator can block MXC telemetry device-wide via MXC's own +Group Policy / MDM setting. `getTelemetryPolicy()` (also the `policy` field +above) reports the result: + +```typescript +import { getTelemetryPolicy } from '@microsoft/mxc-sdk'; + +const policy = getTelemetryPolicy(); +// 'unrestricted' | 'allowed' | 'blocked' | 'not-applicable' +if (policy === 'blocked') { + // Don't show a consent toggle; telemetry is unavailable on this device. +} +``` + +Two things worth designing around: + +- The policy is a **ceiling, never a grant**. `'allowed'` does not mean + telemetry is on — the user must still consent. Only + `consent === 'granted'` *and* a non-blocking policy result in collection. +- When the policy is `'blocked'`, `needsTelemetryConsentPrompt()` is `false`, + because asking for permission an administrator has already refused is a + meaningless question. Word any UI as "telemetry is unavailable on this + device" rather than blaming the user's own choice. + +Like every other consent surface it fails closed: an unreadable or missing +`policy` field reads back as `'blocked'`. On non-Windows hosts it is always +`'not-applicable'`. See +[`docs/telemetry/telemetry-policy.md`](https://github.com/microsoft/mxc/blob/main/docs/telemetry/telemetry-policy.md). + +--- + ## Further Reading - [`docs/schema.md`](https://github.com/microsoft/mxc/blob/main/docs/schema.md) — full configuration schema reference @@ -409,3 +499,4 @@ Full TypeScript definitions ship with the package (`dist/index.d.ts`). All expor ## License [MIT](https://github.com/microsoft/mxc/blob/main/sdk/node/LICENSE.md). Contributions welcome — see the main [MXC repository](https://github.com/microsoft/mxc). + diff --git a/sdk/node/package.json b/sdk/node/package.json index 84066615c..d0075452f 100644 --- a/sdk/node/package.json +++ b/sdk/node/package.json @@ -23,7 +23,7 @@ "watch": "tsc --watch", "clean": "rimraf dist", "test": "npm run test:unit", - "test:unit": "npm run build:test-unit && node --test dist-tests/tests/unit/sandbox.test.js dist-tests/tests/unit/policy.test.js dist-tests/tests/unit/logger.test.js dist-tests/tests/unit/errors.test.js dist-tests/tests/unit/state-aware-types.test.js dist-tests/tests/unit/state-aware.test.js dist-tests/tests/unit/platform.test.js dist-tests/tests/unit/wire-conformance.test.js dist-tests/tests/unit/wire-conformance-state-aware.test.js", + "test:unit": "npm run build:test-unit && node --test dist-tests/tests/unit/sandbox.test.js dist-tests/tests/unit/policy.test.js dist-tests/tests/unit/logger.test.js dist-tests/tests/unit/errors.test.js dist-tests/tests/unit/state-aware-types.test.js dist-tests/tests/unit/state-aware.test.js dist-tests/tests/unit/platform.test.js dist-tests/tests/unit/telemetry.test.js dist-tests/tests/unit/wire-conformance.test.js dist-tests/tests/unit/wire-conformance-state-aware.test.js", "test:integration": "cd tests/integration && npm install && npm run build && npm test", "prepublishOnly": "npm run build" }, diff --git a/sdk/node/src/generated/wire.ts b/sdk/node/src/generated/wire.ts index ad923469c..bf9b47197 100644 --- a/sdk/node/src/generated/wire.ts +++ b/sdk/node/src/generated/wire.ts @@ -458,7 +458,7 @@ export interface Seatbelt { */ export interface Telemetry { /** - * Explicit telemetry override. `true` = force on, `false` = force off, omitted = disabled (default off). + * Explicit telemetry opt-in for this invocation. `true` = opt in (still subject to the user's consent and to administrative policy — it can never turn telemetry on for someone who has not consented), `false` = force off, omitted = off. */ enabled?: boolean | null; [k: string]: unknown; diff --git a/sdk/node/src/index.ts b/sdk/node/src/index.ts index 54df8e634..ba322c79d 100644 --- a/sdk/node/src/index.ts +++ b/sdk/node/src/index.ts @@ -118,3 +118,16 @@ export { stopSandbox, deprovisionSandbox, } from './state-aware.js'; + +// Export telemetry consent functions and types +export { + TelemetryConsentState, + TelemetryConsentSource, + TelemetryConsentQuery, + TelemetryPolicyState, + getTelemetryConsent, + queryTelemetryConsent, + needsTelemetryConsentPrompt, + getTelemetryPolicy, + setTelemetryConsent, +} from './telemetry.js'; diff --git a/sdk/node/src/telemetry.ts b/sdk/node/src/telemetry.ts new file mode 100644 index 000000000..f021e468c --- /dev/null +++ b/sdk/node/src/telemetry.ts @@ -0,0 +1,375 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { execFileSync } from 'child_process'; +import { findWxcExecutable } from './platform.js'; + +/** + * The user's persisted telemetry consent decision. + * + * See `docs/telemetry/telemetry-consent-design.md` for the full design: MXC + * only ever collects telemetry on Windows, and only when this flag is + * `'granted'`. It is stored per-user by MXC itself + * (`%LOCALAPPDATA%\mxc\telemetry-consent.json`) and is never derived from, or + * synchronized with, any Windows-level diagnostics/consent setting (e.g. + * Settings → Privacy → Diagnostics & feedback). + */ +export type TelemetryConsentState = 'granted' | 'denied' | 'undetermined' | 'not-applicable'; + +/** + * Optional, free-form provenance recorded alongside a consent decision for + * support/debugging only. Never transmitted anywhere. + */ +export type TelemetryConsentSource = 'prompt' | 'settings-toggle' | 'cli' | 'sdk' | (string & {}); + +/** + * The administrative (MDM / Group Policy) telemetry decision for this machine. + * + * See `docs/telemetry/telemetry-policy.md`. An administrator can disable MXC + * telemetry machine-wide via Intune, another MDM, or Group Policy. The policy + * is a *ceiling, never a grant*: `'allowed'` does not mean telemetry is on, + * only that an administrator has not forbidden it — an explicit user consent + * grant is still required. + * + * `'blocked'` also covers "the policy could not be determined", so a host + * should word its UI as "telemetry is unavailable on this device" rather than + * asserting that an administrator is responsible. + */ +export type TelemetryPolicyState = 'unrestricted' | 'allowed' | 'blocked' | 'not-applicable'; + +/** + * Runner injection seam: spawns `wxc-exec` with the given telemetry-consent + * flags and returns its stdout. Replaceable in unit tests via + * {@link _setTelemetryConsentRunner} so tests don't require a built + * `wxc-exec.exe` or touch the real consent store. + */ +type ConsentRunner = (args: readonly string[]) => string; + +function defaultConsentRunner(args: readonly string[]): string { + const wxcPath = findWxcExecutable(); + if (!wxcPath) { + // Only reachable on Windows: every public entry point returns early on + // other platforms, so a missing executable here means a broken install, + // not a non-Windows host. Throw rather than synthesising 'not-applicable', + // which would tell the host that this machine never collects telemetry and + // hide the broken install instead of reporting it. + throw new Error('wxc-exec was not found; the MXC native binary is missing from this installation'); + } + return execFileSync(wxcPath, args, { + timeout: 5000, + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +let consentRunner: ConsentRunner = defaultConsentRunner; + +/** @internal Test-only: override the consent CLI runner. */ +export function _setTelemetryConsentRunner(runner: ConsentRunner | null): void { + consentRunner = runner ?? defaultConsentRunner; +} + +let platformOverride: NodeJS.Platform | null = null; + +/** + * @internal Test-only: pretend to be running on the given platform, so the + * Windows-only guards can be exercised from a non-Windows CI machine. + */ +export function _setTelemetryPlatform(platform: NodeJS.Platform | null): void { + platformOverride = platform; +} + +function isWindows(): boolean { + return (platformOverride ?? process.platform) === 'win32'; +} + +function isConsentState(value: unknown): value is TelemetryConsentState { + return value === 'granted' || value === 'denied' || value === 'undetermined' || value === 'not-applicable'; +} + +function isPolicyState(value: unknown): value is TelemetryPolicyState { + return value === 'unrestricted' || value === 'allowed' || value === 'blocked' || value === 'not-applicable'; +} + +/** + * The parsed `--telemetry-consent-status` payload. `needsPrompt` and `policy` + * are produced by Rust (`consent::needs_consent_prompt` and + * `policy::get_policy`) rather than derived here, so the "should the host ask + * the user?" and "has an administrator disabled this?" policies each have + * exactly one implementation across the Rust, C#, and Node SDKs and the CLI. + */ +interface ParsedConsentOutput { + state: TelemetryConsentState; + needsPrompt: boolean; + policy: TelemetryPolicyState; + /** + * Set when the output could not be fully understood, and omitted entirely + * when it could. + * + * The parser is the only thing that knows whether a value was genuinely + * read or substituted by a fail-closed default, so it reports that directly + * rather than leaving the caller to re-derive it by inspecting the raw + * stdout. A returned `'undetermined'` is otherwise indistinguishable from a + * genuine "user has not decided yet". + */ + error?: string; +} + +function parseConsentOutput(stdout: string): ParsedConsentOutput { + // Fail closed: any unexpected output (malformed JSON, unrecognised value) + // is treated as "no consent", never "granted". + const unreadable = (reason: string): ParsedConsentOutput => ({ + state: 'undetermined', + needsPrompt: false, + policy: 'blocked', + error: `${reason}: ${stdout.trim().slice(0, 200)}`, + }); + + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + return unreadable('unrecognised telemetry consent output'); + } + if (parsed === null || typeof parsed !== 'object') { + return unreadable('unrecognised telemetry consent output'); + } + + const record = parsed as Record; + if (!isConsentState(record.consent)) { + return unreadable('unrecognised telemetry consent output'); + } + const state = record.consent; + + // Fail closed: an unrecognised or absent policy field reports 'blocked', + // never the permissive 'unrestricted'. This is a real fault (the SDK + // resolves its own bundled binary, so a missing field means a broken or + // mismatched install) and is reported as such rather than silently + // downgraded — the consent value alone would still look perfectly valid. + if (!isPolicyState(record.policy)) { + return { + state, + needsPrompt: false, + policy: 'blocked', + error: `unrecognised telemetry policy state: ${stdout.trim().slice(0, 200)}`, + }; + } + const policy = record.policy; + + return { + state, + // Fail closed if the field is absent (a wxc-exec older than this SDK): + // never prompt on a guess. A blocked policy also suppresses the prompt + // unconditionally — the native layer already does this, but passing a + // contradictory pair through would have the host prompt for a decision + // that cannot take effect. + needsPrompt: record.needsPrompt === true && policy !== 'blocked', + policy, + }; +} + +/** + * The result of a telemetry-consent query, including *why* the state is what + * it is. + * + * `getTelemetryConsent()` deliberately collapses every failure into + * `'undetermined'` so a status read can never throw. That is the right + * default, but it means a host cannot distinguish "the user has genuinely not + * decided yet" (show the prompt) from "we could not reach `wxc-exec`" (a + * broken install — prompting the user will not help, and the prompt's answer + * cannot be persisted either). Use this when you need to tell those apart, + * e.g. to log a diagnostic or suppress a prompt that is doomed to fail. + */ +export interface TelemetryConsentQuery { + /** The consent state, using the same fail-closed rules as {@link getTelemetryConsent}. */ + state: TelemetryConsentState; + /** + * Whether the host should offer its own consent prompt. Reported by the + * native layer (Rust `ConsentState::needs_prompt`), not derived from + * `state` here, so the policy is identical across the Rust, C#, and Node + * SDKs. Always `false` off Windows, and `false` whenever the state was + * forced by a failure. + */ + needsPrompt: boolean; + /** + * The administrative (MDM / Group Policy) ceiling. Reported by the native + * layer, not derived here. `'blocked'` means nothing is collected regardless + * of `state`, and `needsPrompt` will be `false`. Always `'not-applicable'` + * off Windows, and `'blocked'` whenever the query failed. + */ + policy: TelemetryPolicyState; + /** + * Present only when `state` was forced to `'undetermined'` by a failure + * rather than read from the store. Human-readable, for diagnostics only — + * do not parse it or branch on its contents. + */ + error?: string; +} + +/** + * Report a failure that was swallowed to keep a privacy gate fail-closed. + * + * These paths deliberately return a safe value instead of throwing, which would + * otherwise make a broken install completely silent: the three convenience + * getters discard the {@link TelemetryConsentQuery.error} field entirely. + * + * Reported once per distinct failure per process — a host may poll these + * getters (e.g. to render a settings toggle), and warning on every call would + * be noise rather than signal. + * + * Never throws: it is called from the fail-closed paths whose whole purpose is + * to guarantee the caller cannot crash. + */ +const reportedFailures = new Set(); + +function reportFailClosed(operation: string, safeResult: string, detail: string): void { + try { + const message = `mxc-sdk: ${operation} failed and is reporting '${safeResult}' to stay fail-closed: ${detail}`; + if (reportedFailures.has(message)) { + return; + } + reportedFailures.add(message); + console.warn(message); + } catch { + // Diagnostics must never be able to break the caller. + } +} + +/** @internal Test-only: forget which failures have already been reported. */ +export function _resetTelemetryFailureReporting(): void { + reportedFailures.clear(); +} + +/** + * Read the persisted telemetry consent state, along with any error that + * forced a fail-closed result. See {@link TelemetryConsentQuery}. + * + * Always succeeds — never throws. + */ +export function queryTelemetryConsent(): TelemetryConsentQuery { + // Windows-only by design: MXC never collects telemetry on other platforms, + // so there is nothing to consent to and hosts must not be told a decision + // is pending. This guard must come first — without it, any runner failure + // on macOS/Linux would surface as 'undetermined' and drive hosts into a + // consent prompt they must never show. + if (!isWindows()) { + return { state: 'not-applicable', needsPrompt: false, policy: 'not-applicable' }; + } + let stdout: string; + try { + stdout = consentRunner(['--telemetry-consent-status']); + } catch (e) { + // Fail closed: a spawn failure (missing binary, timeout, non-zero exit) + // must not throw out of a "read-only status" query — treat it the same + // as "no decision yet". + const detail = e instanceof Error ? e.message : String(e); + reportFailClosed('queryTelemetryConsent', 'undetermined', detail); + return { + state: 'undetermined', + needsPrompt: false, + policy: 'blocked', + error: `failed to read telemetry consent: ${detail}`, + }; + } + const { state, needsPrompt, policy, error } = parseConsentOutput(stdout); + if (error !== undefined) { + reportFailClosed('queryTelemetryConsent', state, error); + return { state, needsPrompt, policy, error }; + } + return { state, needsPrompt, policy }; +} + +/** + * Read the persisted telemetry consent state. + * + * Always succeeds — never throws for "no decision yet" or "not on Windows"; + * both are ordinary return values (`'undetermined'` and `'not-applicable'` + * respectively). Use {@link queryTelemetryConsent} if you need to know + * whether an `'undetermined'` result came from the store or from a failure. + * + * Each call spawns `wxc-exec` once. If you need more than one of the consent + * state, the prompt flag, and the policy — as a startup path typically does — + * call {@link queryTelemetryConsent} instead and read all three off the single + * result, rather than calling these convenience getters in sequence. + */ +export function getTelemetryConsent(): TelemetryConsentState { + return queryTelemetryConsent().state; +} + +/** + * Whether the hosting application should show its own first-run telemetry + * consent prompt: `true` only on Windows, when no decision has been recorded + * yet. MXC does not ship a consent UI itself — a hosting agent/SDK consumer + * calls this once (e.g. right before its first `spawnSandbox` call), and if + * it returns `true`, shows its own prompt and then calls + * {@link setTelemetryConsent} with the user's choice. + * + * The answer comes from the native layer (Rust `ConsentState::needs_prompt`) + * rather than being derived from {@link getTelemetryConsent} here, so the + * policy is identical across the Rust, C#, and Node SDKs and the CLI. + * + * Always `false` when an administrator has blocked telemetry: there is no + * decision left for the user to make. Spawns `wxc-exec` once per call; prefer + * {@link queryTelemetryConsent} when you also need the consent state or the + * policy. + */ +export function needsTelemetryConsentPrompt(): boolean { + return queryTelemetryConsent().needsPrompt; +} + +/** + * Read the administrative (MDM / Group Policy) telemetry policy for this + * machine. See {@link TelemetryPolicyState}. + * + * Use this to distinguish "the user has not opted in" from "telemetry is + * unavailable on this device" so a settings surface can explain the + * difference instead of rendering a toggle that silently does nothing. + * + * The policy is a ceiling, never a grant: a `'allowed'` result still requires + * an explicit user consent grant before anything is collected. + * + * Always succeeds — never throws. Fails closed to `'blocked'`. + * + * Spawns `wxc-exec` once per call; prefer {@link queryTelemetryConsent} when + * you also need the consent state or the prompt flag. + */ +export function getTelemetryPolicy(): TelemetryPolicyState { + return queryTelemetryConsent().policy; +} + +/** + * Grant or revoke telemetry consent and persist the decision. + * + * @param granted `true` to grant, `false` to revoke/deny. + * @param source Optional, free-form provenance for support/debugging (e.g. + * `'prompt'`, `'settings-toggle'`). Never transmitted anywhere. Defaults to + * `'sdk'`. + * @throws {Error} if the decision could not be persisted — always the case + * on non-Windows hosts, since MXC must not collect, and therefore must not + * offer consent for, telemetry there. + */ +export function setTelemetryConsent(granted: boolean, source: TelemetryConsentSource = 'sdk'): void { + if (!isWindows()) { + throw new Error( + 'failed to persist telemetry consent: MXC only collects telemetry, and therefore only offers consent, on Windows', + ); + } + const args = [granted ? '--telemetry-consent-grant' : '--telemetry-consent-revoke', '--telemetry-consent-source', source]; + let stdout: string; + try { + stdout = consentRunner(args); + } catch (e) { + throw new Error( + `failed to persist telemetry consent (MXC only collects telemetry, and only offers consent, on Windows): ${ + e instanceof Error ? e.message : String(e) + }`, + ); + } + const { state } = parseConsentOutput(stdout); + const expected: TelemetryConsentState = granted ? 'granted' : 'denied'; + if (state !== expected) { + throw new Error( + `failed to persist telemetry consent (MXC only collects telemetry, and only offers consent, on Windows); reported state: ${state}`, + ); + } +} diff --git a/sdk/node/tests/unit/telemetry.test.ts b/sdk/node/tests/unit/telemetry.test.ts new file mode 100644 index 000000000..d77a8ccce --- /dev/null +++ b/sdk/node/tests/unit/telemetry.test.ts @@ -0,0 +1,400 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert'; +import { + getTelemetryConsent, + queryTelemetryConsent, + needsTelemetryConsentPrompt, + getTelemetryPolicy, + setTelemetryConsent, + _setTelemetryConsentRunner, + _setTelemetryPlatform, + _resetTelemetryFailureReporting, +} from '../../src/telemetry.js'; + +describe('telemetry consent', () => { + // Every test in this block exercises the Windows behaviour via the injected + // runner; pin the platform so the Windows-only guards don't short-circuit + // them on a Linux/macOS CI agent. The non-Windows guards get their own + // block below. + beforeEach(() => { + _setTelemetryPlatform('win32'); + }); + + afterEach(() => { + _setTelemetryConsentRunner(null); + _setTelemetryPlatform(null); + }); + + it('getTelemetryConsent parses a granted response', () => { + _setTelemetryConsentRunner(() => '{"consent":"granted","needsPrompt":false,"policy":"unrestricted"}'); + assert.strictEqual(getTelemetryConsent(), 'granted'); + }); + + it('getTelemetryConsent parses a denied response', () => { + _setTelemetryConsentRunner(() => '{"consent":"denied","needsPrompt":false,"policy":"unrestricted"}'); + assert.strictEqual(getTelemetryConsent(), 'denied'); + }); + + it('getTelemetryConsent parses a not-applicable response from the runner', () => { + _setTelemetryConsentRunner(() => '{"consent":"not-applicable","needsPrompt":false,"policy":"unrestricted"}'); + assert.strictEqual(getTelemetryConsent(), 'not-applicable'); + }); + + it('getTelemetryConsent fails closed to undetermined on malformed output', () => { + _setTelemetryConsentRunner(() => 'not json at all'); + assert.strictEqual(getTelemetryConsent(), 'undetermined'); + }); + + it('getTelemetryConsent fails closed to undetermined on an unrecognised consent value', () => { + _setTelemetryConsentRunner(() => '{"consent":"maybe"}'); + assert.strictEqual(getTelemetryConsent(), 'undetermined'); + }); + + it('getTelemetryConsent never throws when the runner throws (e.g. missing/blocked binary)', () => { + _setTelemetryConsentRunner(() => { + throw new Error('ENOENT: spawn wxc-exec.exe'); + }); + assert.doesNotThrow(() => getTelemetryConsent()); + assert.strictEqual(getTelemetryConsent(), 'undetermined'); + }); + + it('getTelemetryConsent passes --telemetry-consent-status', () => { + let capturedArgs: readonly string[] | undefined; + _setTelemetryConsentRunner((args) => { + capturedArgs = args; + return '{"consent":"undetermined","needsPrompt":true,"policy":"unrestricted"}'; + }); + getTelemetryConsent(); + assert.deepStrictEqual(capturedArgs, ['--telemetry-consent-status']); + }); + + it('needsTelemetryConsentPrompt surfaces the native needsPrompt for every consent state', () => { + _setTelemetryConsentRunner(() => '{"consent":"undetermined","needsPrompt":true,"policy":"unrestricted"}'); + assert.strictEqual(needsTelemetryConsentPrompt(), true); + + _setTelemetryConsentRunner(() => '{"consent":"granted","needsPrompt":false,"policy":"unrestricted"}'); + assert.strictEqual(needsTelemetryConsentPrompt(), false); + + _setTelemetryConsentRunner(() => '{"consent":"denied","needsPrompt":false,"policy":"unrestricted"}'); + assert.strictEqual(needsTelemetryConsentPrompt(), false); + + _setTelemetryConsentRunner(() => '{"consent":"not-applicable","needsPrompt":false,"policy":"unrestricted"}'); + assert.strictEqual(needsTelemetryConsentPrompt(), false); + }); + + it('setTelemetryConsent(true) passes --telemetry-consent-grant and a source', () => { + let capturedArgs: readonly string[] | undefined; + _setTelemetryConsentRunner((args) => { + capturedArgs = args; + return '{"consent":"granted","needsPrompt":false,"policy":"unrestricted"}'; + }); + setTelemetryConsent(true, 'prompt'); + assert.deepStrictEqual(capturedArgs, [ + '--telemetry-consent-grant', + '--telemetry-consent-source', + 'prompt', + ]); + }); + + it('setTelemetryConsent(false) passes --telemetry-consent-revoke', () => { + let capturedArgs: readonly string[] | undefined; + _setTelemetryConsentRunner((args) => { + capturedArgs = args; + return '{"consent":"denied","needsPrompt":false,"policy":"unrestricted"}'; + }); + setTelemetryConsent(false); + assert.deepStrictEqual(capturedArgs, [ + '--telemetry-consent-revoke', + '--telemetry-consent-source', + 'sdk', + ]); + }); + + it('setTelemetryConsent throws when the runner throws (e.g. missing/blocked binary)', () => { + _setTelemetryConsentRunner(() => { + throw new Error('spawn failed'); + }); + assert.throws(() => setTelemetryConsent(true), /failed to persist telemetry consent/); + }); + + it('setTelemetryConsent throws when the reported state does not match the request', () => { + // Simulates a host where wxc-exec refuses the grant/revoke and always + // reports not-applicable. + _setTelemetryConsentRunner(() => '{"consent":"not-applicable","needsPrompt":false,"policy":"unrestricted"}'); + assert.throws(() => setTelemetryConsent(true), /failed to persist telemetry consent/); + }); + + it('queryTelemetryConsent reports no error on a clean read', () => { + _setTelemetryConsentRunner(() => '{"consent":"granted","needsPrompt":false,"policy":"unrestricted"}'); + assert.deepStrictEqual(queryTelemetryConsent(), { + state: 'granted', + needsPrompt: false, + policy: 'unrestricted', + }); + }); + + it('queryTelemetryConsent surfaces the runner failure that forced undetermined', () => { + _setTelemetryConsentRunner(() => { + throw new Error('ENOENT: spawn wxc-exec.exe'); + }); + const result = queryTelemetryConsent(); + assert.strictEqual(result.state, 'undetermined'); + assert.strictEqual(result.needsPrompt, false); + assert.match(result.error ?? '', /ENOENT/); + }); + + it('the built-in runner never reports not-applicable on Windows', () => { + // Regression guard: the default runner used to synthesise a + // 'not-applicable' payload when it could not locate wxc-exec. Every public + // entry point already returns early off Windows, so that branch is only + // reachable on a Windows host with a broken install — where reporting + // 'not-applicable' tells the host this machine never collects telemetry + // and hides the failure instead of surfacing it via `error`. + // + // Uses the real runner (no injection). Where the binary is present this + // spawns a read-only status query; where it is absent the runner throws + // and we fall closed to 'undetermined'. Either way 'not-applicable' is the + // one answer that must never come back on win32. + _setTelemetryConsentRunner(null); + const result = queryTelemetryConsent(); + assert.notStrictEqual(result.state, 'not-applicable'); + assert.notStrictEqual(result.policy, 'not-applicable'); + if (result.error !== undefined) { + assert.strictEqual(result.state, 'undetermined'); + assert.strictEqual(result.policy, 'blocked'); + assert.strictEqual(result.needsPrompt, false); + } + }); + + it('a fail-closed read is reported to the console exactly once per distinct failure', () => { + // The three convenience getters discard the `error` field entirely, so + // without this a broken install is completely silent. Deduplicated because + // a host may poll these getters to render a settings toggle. + _resetTelemetryFailureReporting(); + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => { + warnings.push(args.map(String).join(' ')); + }; + try { + _setTelemetryConsentRunner(() => { + throw new Error('ENOENT: spawn wxc-exec.exe'); + }); + getTelemetryConsent(); + getTelemetryPolicy(); + needsTelemetryConsentPrompt(); + } finally { + console.warn = originalWarn; + } + assert.strictEqual(warnings.length, 1, `expected one warning, got: ${JSON.stringify(warnings)}`); + assert.match(warnings[0]!, /fail-closed/); + assert.match(warnings[0]!, /ENOENT/); + }); + + it('reporting a fail-closed read never throws even if console.warn does', () => { + // The reporter runs on the path whose entire purpose is to guarantee the + // caller cannot crash, so it must not be able to introduce a failure. + _resetTelemetryFailureReporting(); + const originalWarn = console.warn; + console.warn = () => { + throw new Error('console is unavailable'); + }; + try { + _setTelemetryConsentRunner(() => { + throw new Error('ENOENT: spawn wxc-exec.exe'); + }); + assert.doesNotThrow(() => getTelemetryConsent()); + assert.strictEqual(getTelemetryConsent(), 'undetermined'); + } finally { + console.warn = originalWarn; + } + }); + + it('queryTelemetryConsent surfaces malformed output that forced undetermined', () => { + _setTelemetryConsentRunner(() => 'not json at all'); + const result = queryTelemetryConsent(); + assert.strictEqual(result.state, 'undetermined'); + assert.strictEqual(result.needsPrompt, false); + assert.match(result.error ?? '', /unrecognised/); + }); + + it('queryTelemetryConsent reports no error for a genuine undetermined store', () => { + _setTelemetryConsentRunner(() => '{"consent":"undetermined","needsPrompt":true,"policy":"unrestricted"}'); + assert.deepStrictEqual(queryTelemetryConsent(), { + state: 'undetermined', + needsPrompt: true, + policy: 'unrestricted', + }); + }); + + it('queryTelemetryConsent does not mistake garbage containing "undetermined" for a real read', () => { + // Regression: the error was once inferred by searching raw stdout for the + // literal '"undetermined"'. Unparseable output that happens to contain + // that substring then masqueraded as a genuine undecided store, with no + // error and no warning. + _setTelemetryConsentRunner(() => 'garbage "undetermined"'); + const result = queryTelemetryConsent(); + assert.strictEqual(result.state, 'undetermined'); + assert.strictEqual(result.policy, 'blocked'); + assert.match(result.error ?? '', /unrecognised/); + }); + + it('queryTelemetryConsent surfaces a policy parse failure even when consent parsed cleanly', () => { + // Regression: a valid consent value paired with an unrecognised policy + // silently fell back to 'blocked' with no error, because the old + // substring check only ever fired when the *consent* value was + // unreadable. + _setTelemetryConsentRunner(() => '{"consent":"undetermined","needsPrompt":true,"policy":"nonsense"}'); + const result = queryTelemetryConsent(); + assert.strictEqual(result.state, 'undetermined'); + assert.strictEqual(result.policy, 'blocked'); + assert.strictEqual(result.needsPrompt, false); + assert.match(result.error ?? '', /policy/); + }); + + it('needsTelemetryConsentPrompt reports the native answer rather than deriving it', () => { + // The prompt policy lives in Rust (ConsentState::needs_prompt). The SDK + // must not second-guess it by re-deriving `state === 'undetermined'`, + // otherwise the policy would have to be changed in four languages. + _setTelemetryConsentRunner(() => '{"consent":"undetermined","needsPrompt":false,"policy":"unrestricted"}'); + assert.strictEqual(needsTelemetryConsentPrompt(), false); + + _setTelemetryConsentRunner(() => '{"consent":"granted","needsPrompt":true,"policy":"unrestricted"}'); + assert.strictEqual(needsTelemetryConsentPrompt(), true); + }); + + it('needsTelemetryConsentPrompt fails closed when the binary omits needsPrompt', () => { + // A wxc-exec older than this SDK. Never prompt on a guess: the answer + // could not be persisted by that binary's SDK contract anyway. + _setTelemetryConsentRunner(() => '{"consent":"undetermined"}'); + assert.strictEqual(needsTelemetryConsentPrompt(), false); + assert.strictEqual(getTelemetryConsent(), 'undetermined'); + }); + + it('getTelemetryPolicy reports the native answer', () => { + for (const state of ['unrestricted', 'allowed', 'blocked'] as const) { + _setTelemetryConsentRunner(() => `{"consent":"denied","needsPrompt":false,"policy":"${state}"}`); + assert.strictEqual(getTelemetryPolicy(), state); + } + }); + + it('getTelemetryPolicy fails closed to blocked when the field is absent or unrecognised', () => { + // A wxc-exec older than this SDK, or a corrupted payload. Reporting the + // permissive 'unrestricted' on a guess would let a host claim telemetry is + // administratively permitted when it may not be. + _setTelemetryConsentRunner(() => '{"consent":"denied","needsPrompt":false}'); + assert.strictEqual(getTelemetryPolicy(), 'blocked'); + + _setTelemetryConsentRunner(() => '{"consent":"denied","needsPrompt":false,"policy":"whatever"}'); + assert.strictEqual(getTelemetryPolicy(), 'blocked'); + }); + + it('getTelemetryPolicy fails closed to blocked when the runner fails', () => { + _setTelemetryConsentRunner(() => { + throw new Error('ENOENT: spawn wxc-exec.exe'); + }); + assert.strictEqual(getTelemetryPolicy(), 'blocked'); + + _setTelemetryConsentRunner(() => 'not json at all'); + assert.strictEqual(getTelemetryPolicy(), 'blocked'); + }); + + it('an administrative block suppresses the prompt without erasing the user decision', () => { + // The CLI already suppresses `needsPrompt` under a blocking policy; the + // SDK must pass that through verbatim and still report the user's own + // recorded state, so a host can explain the situation accurately. + _setTelemetryConsentRunner(() => '{"consent":"granted","needsPrompt":false,"policy":"blocked"}'); + assert.strictEqual(needsTelemetryConsentPrompt(), false); + assert.strictEqual(getTelemetryConsent(), 'granted'); + assert.strictEqual(getTelemetryPolicy(), 'blocked'); + }); + + it('never reports needsPrompt alongside a blocked policy', () => { + // A wxc-exec older than this SDK omits the policy field, so we default it + // to 'blocked' — but it would still report needsPrompt:true on a fresh + // store. Passing that pair through would have the host prompt for a + // decision it simultaneously claims cannot take effect. + _setTelemetryConsentRunner(() => '{"consent":"undetermined","needsPrompt":true}'); + const query = queryTelemetryConsent(); + assert.strictEqual(query.policy, 'blocked'); + assert.strictEqual(query.needsPrompt, false); + assert.strictEqual(needsTelemetryConsentPrompt(), false); + + // Same coercion when the policy field is present but blocking and the + // native layer somehow disagrees with itself. + _setTelemetryConsentRunner( + () => '{"consent":"undetermined","needsPrompt":true,"policy":"blocked"}', + ); + assert.strictEqual(needsTelemetryConsentPrompt(), false); + }); + + it('queryTelemetryConsent answers all three questions from a single spawn', () => { + // The convenience getters each spawn wxc-exec, so a startup path that + // needs more than one field must use this instead of calling them in + // sequence. Guards that the snapshot really is a single invocation. + let calls = 0; + _setTelemetryConsentRunner(() => { + calls += 1; + return '{"consent":"granted","needsPrompt":false,"policy":"allowed"}'; + }); + + const query = queryTelemetryConsent(); + assert.strictEqual(calls, 1); + assert.strictEqual(query.state, 'granted'); + assert.strictEqual(query.needsPrompt, false); + assert.strictEqual(query.policy, 'allowed'); + }); +}); + +describe('telemetry consent is Windows-only', () => { + afterEach(() => { + _setTelemetryConsentRunner(null); + _setTelemetryPlatform(null); + }); + + for (const platform of ['linux', 'darwin'] as const) { + it(`getTelemetryConsent returns not-applicable on ${platform} even if the runner fails`, () => { + _setTelemetryPlatform(platform); + _setTelemetryConsentRunner(() => { + throw new Error('this must never be called'); + }); + assert.strictEqual(getTelemetryConsent(), 'not-applicable'); + }); + + it(`needsTelemetryConsentPrompt is false on ${platform} even if the runner fails`, () => { + // The load-bearing assertion: MXC must never drive a host into showing + // a telemetry consent prompt on a platform where it collects nothing. + _setTelemetryPlatform(platform); + _setTelemetryConsentRunner(() => { + throw new Error('this must never be called'); + }); + assert.strictEqual(needsTelemetryConsentPrompt(), false); + }); + + it(`setTelemetryConsent throws on ${platform} without spawning anything`, () => { + _setTelemetryPlatform(platform); + let called = false; + _setTelemetryConsentRunner(() => { + called = true; + return '{"consent":"granted","needsPrompt":false,"policy":"unrestricted"}'; + }); + assert.throws(() => setTelemetryConsent(true), /only.*on Windows/); + assert.strictEqual(called, false); + }); + + it(`getTelemetryPolicy is not-applicable on ${platform} even if the runner fails`, () => { + // Administrative policy is only meaningful where telemetry can be + // collected. Reporting 'blocked' here would wrongly imply an + // administrator had acted. + _setTelemetryPlatform(platform); + _setTelemetryConsentRunner(() => { + throw new Error('this must never be called'); + }); + assert.strictEqual(getTelemetryPolicy(), 'not-applicable'); + }); + } +}); + diff --git a/src/Cargo.lock b/src/Cargo.lock index 831cbfbac..b5da20a4a 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -1521,6 +1521,7 @@ dependencies = [ "csbindgen", "mxc-sdk", "serde_json", + "wxc_common", ] [[package]] diff --git a/src/core/lxc/src/main.rs b/src/core/lxc/src/main.rs index 3aab93a1b..e71c8ed2d 100644 --- a/src/core/lxc/src/main.rs +++ b/src/core/lxc/src/main.rs @@ -73,6 +73,54 @@ struct Cli { /// the new bits. Requires --setup-hyperlight. #[arg(long, requires = "setup_hyperlight")] force: bool, + + /// Report the persisted telemetry consent state and exit. MXC only + /// ever collects telemetry on Windows, so on Linux this always + /// reports "not-applicable" — there is no consent to grant or + /// revoke here. See docs/telemetry/telemetry-consent-design.md. + #[arg(long = "telemetry-consent-status")] + telemetry_consent_status: bool, + + /// Not supported on Linux; always fails. Present for CLI-surface + /// parity with wxc-exec.exe. + #[arg(long = "telemetry-consent-grant")] + telemetry_consent_grant: bool, + + /// Not supported on Linux; always fails. Present for CLI-surface + /// parity with wxc-exec.exe. + #[arg(long = "telemetry-consent-revoke")] + telemetry_consent_revoke: bool, + + /// Unused on Linux; accepted only for CLI-surface parity. + #[arg(long = "telemetry-consent-source")] + telemetry_consent_source: Option, +} + +/// See `wxc::handle_telemetry_consent_flags` for the Windows behavior this +/// mirrors. On Linux, `wxc_common::telemetry::consent` always reports +/// `NotApplicable` and rejects writes, so `--telemetry-consent-grant`/ +/// `-revoke` fail loudly here instead of pretending to record a decision +/// MXC can never act on (MXC must never gather telemetry off Windows). +/// Delegates to the shared `wxc_common::telemetry::consent_cli` handler so +/// this fast path can't drift from `wxc-exec`/`mxc-exec-mac`. The shared +/// handler returns the outcome as data; terminating the process is this +/// binary's job, not the foundation crate's. +fn handle_telemetry_consent_flags(cli: &Cli) -> bool { + let Some(outcome) = + telemetry::consent_cli::handle_consent_flags(&telemetry::consent_cli::ConsentCliFlags { + status: cli.telemetry_consent_status, + grant: cli.telemetry_consent_grant, + revoke: cli.telemetry_consent_revoke, + source: cli.telemetry_consent_source.as_deref(), + }) + else { + return false; + }; + let code = outcome.emit(); + if code != 0 { + std::process::exit(code); + } + true } fn log_request(request: &ExecutionRequest, logger: &mut Logger) { @@ -113,6 +161,19 @@ fn delete_lxc_container(name: &str, logger: &mut Logger) -> bool { } fn main() { + let cli = Cli::parse(); + + // --telemetry-consent-{status,grant,revoke}: report/administer the + // (always not-applicable on Linux) consent state and exit. Runs BEFORE + // signal_cleanup::install() (unlike a prior version of this function): + // this is a read-only/local-file fast path that never spawns a + // container, so it must not be gated on — or fail because of — signal + // handler installation, matching `wxc-exec`/`mxc-exec-mac`, where the + // consent fast path also runs unconditionally before any other setup. + if handle_telemetry_consent_flags(&cli) { + return; + } + // Install before spawning any other threads so the signal mask propagates. // Failure here is fatal: install() either succeeds with the watchdog // running, or restores the original signal mask and returns Err. We @@ -123,8 +184,6 @@ fn main() { process::exit(1); } - let cli = Cli::parse(); - // --setup-hyperlight: eagerly warm up the snapshot and exit. Runs // before config parsing so the user doesn't need a JSON file on // disk just to install. diff --git a/src/core/mxc-sdk/README.md b/src/core/mxc-sdk/README.md index 240d36b4f..dd9adf81e 100644 --- a/src/core/mxc-sdk/README.md +++ b/src/core/mxc-sdk/README.md @@ -175,6 +175,72 @@ Any other backend (Windows Sandbox, IsolationSession, MicroVM, Hyperlight, WSLC, LXC) returns an [`Error`] with [`ErrorCode::UnsupportedContainment`]; drive the standalone executor binaries for those. +## Telemetry consent + +MXC only ever collects telemetry on Windows, and only after the end user has +explicitly opted in — a persisted, MXC-owned consent flag gates every +emission (never a Windows-level setting like Diagnostics & feedback). See +[`docs/telemetry/telemetry-consent-design.md`](../../../docs/telemetry/telemetry-consent-design.md) +for the full design. + +The crate is UI-agnostic: it does not render a prompt. Call +`needs_consent_prompt()` once at first sandbox run, show your own UI, then +record the answer — and let a settings surface flip it at any later time. + +```rust,no_run +use mxc_sdk::telemetry; + +if telemetry::needs_consent_prompt() { + // Show your own consent UI, then record the user's choice: + telemetry::set_consent(user_opted_in, "prompt")?; +} + +// Anywhere later, e.g. a settings toggle: +let _state = telemetry::get_consent(); +telemetry::set_consent(false, "settings-toggle")?; +# Ok::<(), Box>(()) +``` + +Off Windows `get_consent()` always returns `ConsentState::NotApplicable`, +`needs_consent_prompt()` is always `false`, and `set_consent(..)` always +fails — MXC neither collects nor offers consent for telemetry there, so a +host can call these unconditionally without special-casing the platform. + +`ConsentState` and `PolicyState` are SDK-owned types, so the public API never +leaks the internal `wxc_common` foundation crate. The *decision logic* behind +them is not duplicated: every function here delegates to +`wxc_common::telemetry`, the same code the `wxc-exec` CLI flags, the C# SDK +(via `mxc_ffi`), and the Node SDK all resolve consent through. There is +deliberately no Rust-SDK-specific consent logic to drift. + +### Administrative policy + +An IT administrator can block MXC telemetry device-wide via MXC's own +Group Policy / MDM setting. `telemetry::get_policy()` reports the result: + +```rust,no_run +use mxc_sdk::telemetry::{self, PolicyState}; + +if telemetry::get_policy() == PolicyState::Blocked { + // Don't show a consent toggle; telemetry is unavailable on this device. +} +``` + +Two things worth designing around: + +- The policy is a **ceiling, never a grant**. `PolicyState::Allowed` does not + mean telemetry is on — the user must still consent. Only + `ConsentState::Granted` *and* a non-blocking policy result in collection. +- When the policy blocks, `needs_consent_prompt()` is `false`, because asking + for permission an administrator has already refused is a meaningless + question. Word any UI as "telemetry is unavailable on this device" rather + than blaming the user's own choice. + +It never fails: any unreadable or unrecognized value reads back as +`PolicyState::Blocked`. Off Windows it is always `PolicyState::NotApplicable`. +`telemetry::is_blocked_by_policy()` is the convenience predicate. See +[`docs/telemetry/telemetry-policy.md`](../../../docs/telemetry/telemetry-policy.md). + ## No pty The child's stdio is always wired to ordinary pipes — the library never diff --git a/src/core/mxc-sdk/src/lib.rs b/src/core/mxc-sdk/src/lib.rs index 2ced03875..f237db60c 100644 --- a/src/core/mxc-sdk/src/lib.rs +++ b/src/core/mxc-sdk/src/lib.rs @@ -80,6 +80,8 @@ mod sandbox; +pub mod telemetry; + pub use mxc_engine::policy; pub use mxc_engine::{ available_tools_policy, build_request, platform_support, temporary_files_policy, diff --git a/src/core/mxc-sdk/src/telemetry.rs b/src/core/mxc-sdk/src/telemetry.rs new file mode 100644 index 000000000..7f8595a48 --- /dev/null +++ b/src/core/mxc-sdk/src/telemetry.rs @@ -0,0 +1,283 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Telemetry consent administration. +//! +//! MXC only ever collects telemetry on Windows, and only when the user has +//! explicitly granted consent. Consent is persisted by MXC itself, per Windows +//! user, and is never derived from or synchronized with any Windows-level +//! diagnostics setting. See `docs/telemetry/telemetry-consent-design.md`. +//! +//! [`ConsentState`] and [`PolicyState`] are crate-owned facades over the +//! internal `wxc_common` types, so the public API never exposes the foundation +//! crate — the same approach taken for [`crate::ErrorCode`]. +//! +//! The *decision logic* is not duplicated here: every function and predicate +//! below delegates to the single implementation in `wxc_common::telemetry` — +//! the same code the `wxc-exec` `--telemetry-consent-*` flags, the C ABI +//! (`mxc_ffi`), the C# SDK, and the Node SDK all resolve to. There is no +//! Rust-SDK-specific consent logic to drift. +//! +//! The SDK ships no consent UI. A host calls [`needs_consent_prompt`] once +//! (e.g. before its first sandbox run), shows its own prompt if that returns +//! `true`, and records the answer with [`set_consent`]. A settings surface can +//! call [`set_consent`] again at any later time to let the user change their +//! mind. +//! +//! ```no_run +//! use mxc_sdk::telemetry::{needs_consent_prompt, set_consent, get_consent, ConsentState}; +//! +//! if needs_consent_prompt() { +//! // Show your own consent UI, then record the user's choice. +//! let opted_in = true; +//! set_consent(opted_in, "prompt").expect("Windows host"); +//! } +//! +//! // Anywhere later, e.g. a settings toggle: +//! match get_consent() { +//! ConsentState::Granted => println!("telemetry on"), +//! ConsentState::Denied | ConsentState::Undetermined => println!("telemetry off"), +//! // Never offer a toggle here — MXC collects nothing off Windows. +//! ConsentState::NotApplicable => {} +//! } +//! ``` +//! +//! Off Windows, [`get_consent`] always returns +//! [`ConsentState::NotApplicable`] without touching disk, +//! [`needs_consent_prompt`] is always `false`, and [`set_consent`] always +//! returns `Err` — MXC must not pretend to accept consent it can never act on. +//! +//! # Administrative policy +//! +//! An administrator may disable MXC telemetry machine-wide via MDM (Intune) or +//! Group Policy. [`get_policy`] reports that state so a host can explain *why* +//! telemetry is unavailable instead of rendering an inert toggle: +//! +//! ```no_run +//! use mxc_sdk::telemetry::{get_policy, PolicyState}; +//! +//! if get_policy() == PolicyState::Blocked { +//! println!("Telemetry has been disabled by your administrator."); +//! } +//! ``` +//! +//! The policy is a ceiling, never a grant: an administrator who permits +//! telemetry has not consented on the user's behalf, so an explicit user grant +//! is still required. A blocking policy also makes [`needs_consent_prompt`] +//! return `false`, so a host following the pattern above will not ask the user +//! to decide something MXC would then ignore. + +use wxc_common::telemetry::consent as inner_consent; +use wxc_common::telemetry::policy as inner_policy; + +/// The user's recorded telemetry consent decision. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ConsentState { + /// The user has explicitly agreed to telemetry collection. + Granted, + /// The user has explicitly declined telemetry collection. + Denied, + /// No decision has been recorded yet (fresh install, or a corrupt or + /// unreadable store). Treated identically to [`ConsentState::Denied`] for + /// gating purposes — it differs only in that a host should still prompt. + Undetermined, + /// Not a Windows host. MXC collects no telemetry on other platforms, so + /// there is nothing to consent to. + NotApplicable, +} + +impl ConsentState { + /// The stable wire string for this state, identical across every SDK. + pub fn as_str(&self) -> &'static str { + Self::to_inner(*self).as_str() + } + + /// Whether this state alone permits collection. Never sufficient on its + /// own — collection additionally requires a permitting policy. + pub fn allows_collection(&self) -> bool { + Self::to_inner(*self).allows_collection() + } + + /// Whether a host should show the first-run consent prompt for this state, + /// ignoring administrative policy. Prefer [`needs_consent_prompt`], which + /// also accounts for a blocking policy. + pub fn needs_prompt(&self) -> bool { + Self::to_inner(*self).needs_prompt() + } + + fn to_inner(self) -> inner_consent::ConsentState { + match self { + Self::Granted => inner_consent::ConsentState::Granted, + Self::Denied => inner_consent::ConsentState::Denied, + Self::Undetermined => inner_consent::ConsentState::Undetermined, + Self::NotApplicable => inner_consent::ConsentState::NotApplicable, + } + } +} + +impl From for ConsentState { + fn from(value: inner_consent::ConsentState) -> Self { + match value { + inner_consent::ConsentState::Granted => Self::Granted, + inner_consent::ConsentState::Denied => Self::Denied, + inner_consent::ConsentState::Undetermined => Self::Undetermined, + inner_consent::ConsentState::NotApplicable => Self::NotApplicable, + } + } +} + +/// The administrative (MDM / Group Policy) telemetry ceiling for this machine. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PolicyState { + /// No policy is configured — an unmanaged machine. Collection is governed + /// by the user's consent alone. + Unrestricted, + /// A policy is configured and permits collection. This is a ceiling, not a + /// grant: an explicit user consent is still required. + Allowed, + /// A policy blocks collection, or the configured policy could not be read + /// or understood (fail closed). + Blocked, + /// Not a Windows host, where there is no telemetry to govern. + NotApplicable, +} + +impl PolicyState { + /// The stable wire string for this state, identical across every SDK. + pub fn as_str(&self) -> &'static str { + Self::to_inner(*self).as_str() + } + + /// Whether this policy permits collection. Never sufficient on its own — + /// a policy can restrict, but can never consent on the user's behalf. + pub fn allows_collection(&self) -> bool { + Self::to_inner(*self).allows_collection() + } + + fn to_inner(self) -> inner_policy::PolicyState { + match self { + Self::Unrestricted => inner_policy::PolicyState::Unrestricted, + Self::Allowed => inner_policy::PolicyState::Allowed, + Self::Blocked => inner_policy::PolicyState::Blocked, + Self::NotApplicable => inner_policy::PolicyState::NotApplicable, + } + } +} + +impl From for PolicyState { + fn from(value: inner_policy::PolicyState) -> Self { + match value { + inner_policy::PolicyState::Unrestricted => Self::Unrestricted, + inner_policy::PolicyState::Allowed => Self::Allowed, + inner_policy::PolicyState::Blocked => Self::Blocked, + inner_policy::PolicyState::NotApplicable => Self::NotApplicable, + } + } +} + +/// The user's currently recorded consent decision. Never panics; an unreadable +/// store reports [`ConsentState::Undetermined`]. +pub fn get_consent() -> ConsentState { + inner_consent::get_consent().into() +} + +/// Record the user's decision. Returns `Err` on a non-Windows host, and on +/// Windows if the decision could not be persisted — a caller must not treat a +/// failed write as consent. +pub fn set_consent(granted: bool, source: &str) -> Result<(), String> { + inner_consent::set_consent(granted, source) +} + +/// Whether a host should show the first-run consent prompt. `false` when a +/// policy blocks collection, so a host never asks the user to decide something +/// MXC would then ignore. Never panics. +pub fn needs_consent_prompt() -> bool { + inner_consent::needs_consent_prompt() +} + +/// The administrative telemetry ceiling for this machine. Never panics; an +/// unreadable or unrecognised policy reports [`PolicyState::Blocked`]. +pub fn get_policy() -> PolicyState { + inner_policy::get_policy().into() +} + +/// Whether an administrator has blocked telemetry on this machine. +pub fn is_blocked_by_policy() -> bool { + inner_policy::is_blocked_by_policy() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The facade must map every variant, and must agree with the shared + /// implementation on the wire string — a host or a sibling SDK comparing + /// these strings would otherwise silently diverge. + #[test] + fn consent_states_round_trip_and_keep_their_wire_strings() { + for (facade, inner) in [ + (ConsentState::Granted, inner_consent::ConsentState::Granted), + (ConsentState::Denied, inner_consent::ConsentState::Denied), + ( + ConsentState::Undetermined, + inner_consent::ConsentState::Undetermined, + ), + ( + ConsentState::NotApplicable, + inner_consent::ConsentState::NotApplicable, + ), + ] { + assert_eq!(ConsentState::from(inner), facade); + assert_eq!(facade.as_str(), inner.as_str()); + assert_eq!(facade.allows_collection(), inner.allows_collection()); + assert_eq!(facade.needs_prompt(), inner.needs_prompt()); + } + } + + #[test] + fn policy_states_round_trip_and_keep_their_wire_strings() { + for (facade, inner) in [ + ( + PolicyState::Unrestricted, + inner_policy::PolicyState::Unrestricted, + ), + (PolicyState::Allowed, inner_policy::PolicyState::Allowed), + (PolicyState::Blocked, inner_policy::PolicyState::Blocked), + ( + PolicyState::NotApplicable, + inner_policy::PolicyState::NotApplicable, + ), + ] { + assert_eq!(PolicyState::from(inner), facade); + assert_eq!(facade.as_str(), inner.as_str()); + assert_eq!(facade.allows_collection(), inner.allows_collection()); + } + } + + /// Only an explicit grant may permit collection. Locks in that the facade + /// did not accidentally widen the shared rule. + #[test] + fn only_granted_consent_and_a_permitting_policy_allow_collection() { + assert!(ConsentState::Granted.allows_collection()); + for denied in [ + ConsentState::Denied, + ConsentState::Undetermined, + ConsentState::NotApplicable, + ] { + assert!(!denied.allows_collection(), "{denied:?} must not permit"); + } + + // Only an explicit block denies on the policy side. `NotApplicable` + // (off Windows) deliberately does *not* deny: the consent gate above + // already reports `NotApplicable`, and denying here too would wrongly + // imply an administrator had acted. + assert!(!PolicyState::Blocked.allows_collection()); + for permitted in [ + PolicyState::Unrestricted, + PolicyState::Allowed, + PolicyState::NotApplicable, + ] { + assert!(permitted.allows_collection(), "{permitted:?} must permit"); + } + } +} diff --git a/src/core/mxc_darwin/src/main.rs b/src/core/mxc_darwin/src/main.rs index e5f466500..6f7c8aae3 100644 --- a/src/core/mxc_darwin/src/main.rs +++ b/src/core/mxc_darwin/src/main.rs @@ -61,6 +61,54 @@ struct Cli { /// Path to diagnostic log file (appends, creates if missing) #[arg(long = "log-file")] log_file: Option, + + /// Report the persisted telemetry consent state and exit. MXC only + /// ever collects telemetry on Windows, so on macOS this always + /// reports "not-applicable" — there is no consent to grant or + /// revoke here. See docs/telemetry/telemetry-consent-design.md. + #[arg(long = "telemetry-consent-status")] + telemetry_consent_status: bool, + + /// Not supported on macOS; always fails. Present for CLI-surface + /// parity with wxc-exec.exe. + #[arg(long = "telemetry-consent-grant")] + telemetry_consent_grant: bool, + + /// Not supported on macOS; always fails. Present for CLI-surface + /// parity with wxc-exec.exe. + #[arg(long = "telemetry-consent-revoke")] + telemetry_consent_revoke: bool, + + /// Unused on macOS; accepted only for CLI-surface parity. + #[arg(long = "telemetry-consent-source")] + telemetry_consent_source: Option, +} + +/// See `wxc::handle_telemetry_consent_flags` for the Windows behavior this +/// mirrors. On macOS, `wxc_common::telemetry::consent` always reports +/// `NotApplicable` and rejects writes, so `--telemetry-consent-grant`/ +/// `-revoke` fail loudly here instead of pretending to record a decision +/// MXC can never act on (MXC must never gather telemetry off Windows). +/// Delegates to the shared `wxc_common::telemetry::consent_cli` handler so +/// this fast path can't drift from `wxc-exec`/`lxc-exec`. The shared handler +/// returns the outcome as data; terminating the process is this binary's +/// job, not the foundation crate's. +fn handle_telemetry_consent_flags(cli: &Cli) -> bool { + let Some(outcome) = wxc_common::telemetry::consent_cli::handle_consent_flags( + &wxc_common::telemetry::consent_cli::ConsentCliFlags { + status: cli.telemetry_consent_status, + grant: cli.telemetry_consent_grant, + revoke: cli.telemetry_consent_revoke, + source: cli.telemetry_consent_source.as_deref(), + }, + ) else { + return false; + }; + let code = outcome.emit(); + if code != 0 { + std::process::exit(code); + } + true } fn log_request(request: &ExecutionRequest, logger: &mut Logger) { @@ -82,6 +130,12 @@ fn display_script_results(response: &ScriptResponse, logger: &mut Logger) { fn main() { let cli = Cli::parse(); + // --telemetry-consent-{status,grant,revoke}: report/administer the + // (always not-applicable on macOS) consent state and exit. + if handle_telemetry_consent_flags(&cli) { + return; + } + // Determine config input. let (config_data, is_base64) = if let Some(ref b64) = cli.config_base64 { (b64.clone(), true) diff --git a/src/core/wxc/src/main.rs b/src/core/wxc/src/main.rs index aa9529df8..ac38f9d5c 100644 --- a/src/core/wxc/src/main.rs +++ b/src/core/wxc/src/main.rs @@ -111,6 +111,34 @@ struct Cli { #[arg(long)] probe: bool, + /// Print the current persisted telemetry consent state as one-line JSON + /// and exit, without spawning a sandbox. The payload is + /// `{"consent":...,"needsPrompt":...,"policy":...}`. Windows-only: on + /// other platforms every field reports `not-applicable`/`false` and + /// nothing touches disk, since MXC does not collect telemetry there. See + /// `docs/telemetry/telemetry-consent-design.md`. + #[arg(long = "telemetry-consent-status")] + telemetry_consent_status: bool, + + /// Persist telemetry consent as granted for the current Windows user and + /// exit. Fails with a clear error on non-Windows platforms — MXC must + /// not accept a consent decision it can never act on. + #[arg(long = "telemetry-consent-grant")] + telemetry_consent_grant: bool, + + /// Persist telemetry consent as denied for the current Windows user and + /// exit. Fails with a clear error on non-Windows platforms, for the same + /// reason as `--telemetry-consent-grant`. + #[arg(long = "telemetry-consent-revoke")] + telemetry_consent_revoke: bool, + + /// Provenance recorded alongside a `--telemetry-consent-grant` / + /// `--telemetry-consent-revoke` decision (e.g. `"prompt"`, + /// `"settings-toggle"`). Defaults to `"cli"` when omitted. Never + /// transmitted anywhere; local diagnostic metadata only. + #[arg(long = "telemetry-consent-source")] + telemetry_consent_source: Option, + /// Windows Sandbox: tear down a running WSB VM that mxc cannot prove it /// launched, instead of refusing — clears a host wedged by an orphan left /// after a launcher hard-kill. DANGER: proofless, so it may also kill a @@ -212,6 +240,37 @@ fn validate_audit_containment(containment: &ContainmentBackend) -> Result<(), St } } +/// Handles the `--telemetry-consent-{status,grant,revoke}` fast paths. +/// +/// Returns `true` if one of the flags was handled (and the caller should +/// exit immediately), `false` if none were passed and normal execution +/// should proceed. Never spawns a sandbox, never touches config parsing, and +/// runs before COM/WinRT init — mirroring the `--probe` fast path. +/// +/// Delegates to the shared `wxc_common::telemetry::consent_cli` handler +/// (identical across all three executors) so this fast path can't drift +/// between `wxc-exec`, `lxc-exec`, and `mxc-exec-mac`. The shared handler +/// returns the outcome as data; terminating the process is this binary's +/// job, not the foundation crate's. See +/// `docs/telemetry/telemetry-consent-design.md`. +fn handle_telemetry_consent_flags(cli: &Cli) -> bool { + let Some(outcome) = + telemetry::consent_cli::handle_consent_flags(&telemetry::consent_cli::ConsentCliFlags { + status: cli.telemetry_consent_status, + grant: cli.telemetry_consent_grant, + revoke: cli.telemetry_consent_revoke, + source: cli.telemetry_consent_source.as_deref(), + }) + else { + return false; + }; + let code = outcome.emit(); + if code != 0 { + std::process::exit(code); + } + true +} + fn command_override_from_cli( cli: &Cli, context: CommandLineContext, @@ -771,6 +830,13 @@ fn main() { Err(e) => eprintln!("DACL recovery failed: {e}"), } + // --telemetry-consent-{status,grant,revoke}: administer the persisted + // consent flag and exit. Run before --probe (cheapest possible fast + // path — no config parsing, no policy defaults needed). + if handle_telemetry_consent_flags(&cli) { + return; + } + // --probe is a detection-only fast path used by SDK // `getPlatformSupport()` on every first call. It does not spawn a // sandbox, never parks a DaclManager, and never calls into COM/WinRT. diff --git a/src/core/wxc_common/Cargo.toml b/src/core/wxc_common/Cargo.toml index 339674f13..4b70272c9 100644 --- a/src/core/wxc_common/Cargo.toml +++ b/src/core/wxc_common/Cargo.toml @@ -9,6 +9,12 @@ microvm = ["dep:nanvix_common", "dep:uuid"] # Enables JSON Schema generation from the dedicated wire model. Off by default # so normal builds don't carry schemars. schema-gen = ["dep:schemars"] +# Exposes the telemetry test harnesses (the policy-key redirector) outside this +# crate's own unit tests, so downstream crates — notably `mxc_ffi` — can drive +# exact administrative-policy states instead of asserting weak set membership +# against whatever policy the host machine happens to have. Test-only: never +# enable it for a shipping build. +test-support = [] [dependencies] serde = { workspace = true } diff --git a/src/core/wxc_common/src/models.rs b/src/core/wxc_common/src/models.rs index 627fdc92b..d34041562 100644 --- a/src/core/wxc_common/src/models.rs +++ b/src/core/wxc_common/src/models.rs @@ -727,8 +727,9 @@ pub struct ExperimentalConfig { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default)] pub struct TelemetryConfig { - /// Explicit telemetry override. - /// `Some(true)` = force on, `Some(false)` = force off, `None` = disabled (default off). + /// Explicit telemetry opt-in for this invocation. + /// `Some(true)` = opt in (still subject to consent and policy), + /// `Some(false)` = force off, `None` = off. pub enabled: Option, } diff --git a/src/core/wxc_common/src/telemetry/consent.rs b/src/core/wxc_common/src/telemetry/consent.rs new file mode 100644 index 000000000..2e6613a37 --- /dev/null +++ b/src/core/wxc_common/src/telemetry/consent.rs @@ -0,0 +1,835 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Persisted, per-user telemetry consent. +//! +//! See `docs/telemetry/telemetry-consent-design.md` for the full design and +//! privacy rationale. In short: +//! +//! - MXC does not and must not collect telemetry on any platform other than +//! Windows, so this module is the **single** consent surface — and it is +//! compiled out entirely on non-Windows targets, replaced by a stub that +//! never touches disk and always reports [`ConsentState::NotApplicable`]. +//! - On Windows, consent is a per-user choice persisted at +//! `%LOCALAPPDATA%\mxc\telemetry-consent.json`. The default (no file, or an +//! unreadable/corrupt one) is [`ConsentState::Undetermined`], which is +//! treated as "not collecting" everywhere telemetry gating is decided +//! ([`super::is_enabled`]) — MXC fails closed, never open. +//! - This module never emits a telemetry event for a consent transition +//! itself; flipping the flag is a silent, local, atomic file write. + +// Serde, the persisted record, and its helpers exist only for the Windows +// consent store; the non-Windows stub persists nothing. +#[cfg(target_os = "windows")] +use serde::{Deserialize, Serialize}; + +/// Current schema version for the persisted consent record. Bump when the +/// on-disk shape changes in a way that isn't purely additive; unknown/older +/// versions are treated as [`ConsentState::Undetermined`] on read (fail +/// closed) rather than guessed at. +#[cfg(target_os = "windows")] +const CONSENT_SCHEMA_VERSION: u32 = 1; + +/// The user's telemetry consent decision. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConsentState { + /// The user has explicitly agreed to telemetry collection. + Granted, + /// The user has explicitly declined telemetry collection. + Denied, + /// No decision has been recorded yet (fresh install, or a corrupt/missing + /// store). Treated identically to `Denied` for gating purposes — the only + /// difference is that a host application should offer a first-run prompt + /// when it sees this state. + Undetermined, + /// Not a Windows host. MXC does not collect telemetry here, so consent is + /// not a meaningful concept — hosts must not offer a consent prompt at + /// all on these platforms. + NotApplicable, +} + +impl ConsentState { + /// Stable, lowercase wire representation used by the CLI flags, the FFI + /// boundary, and the SDKs. Kept separate from `Debug` so the on-the-wire + /// strings never drift if the Rust variant names change. + pub fn as_str(&self) -> &'static str { + match self { + ConsentState::Granted => "granted", + ConsentState::Denied => "denied", + ConsentState::Undetermined => "undetermined", + ConsentState::NotApplicable => "not-applicable", + } + } + + /// Whether telemetry may be collected under this consent state alone + /// (still subject to the explicit config kill-switch — see + /// [`super::is_enabled`]). + pub fn allows_collection(&self) -> bool { + matches!(self, ConsentState::Granted) + } + + /// Whether a hosting application should offer its own first-run consent + /// prompt for this state. + /// + /// This is the single definition of that policy for every MXC consumer + /// surface — the Rust SDK, the C ABI, the C# SDK, the Node SDK, and the + /// `wxc-exec --telemetry-consent-status` JSON all derive their answer + /// from here rather than re-deriving `state == Undetermined` in their own + /// language. If the policy ever grows (e.g. re-prompting after a + /// materially changed data-collection scope), it changes here once. + /// + /// Never true for [`NotApplicable`](ConsentState::NotApplicable): MXC + /// collects no telemetry off Windows, so there is nothing to consent to + /// and a prompt would be asking the user to decide something moot. + pub fn needs_prompt(&self) -> bool { + matches!(self, ConsentState::Undetermined) + } +} + +/// Whether a hosting application should offer its own first-run telemetry +/// consent prompt right now. +/// +/// This is [`get_consent`]`().`[`needs_prompt`](ConsentState::needs_prompt)`()` +/// additionally suppressed when an administrator has denied telemetry via +/// [`super::policy`]. Prompting under an administrative denial would be asking +/// the user to decide something MXC would then ignore, so the answer there is +/// always `false` — regardless of whether a decision has been recorded. +pub fn needs_consent_prompt() -> bool { + if super::policy::is_blocked_by_policy() { + return false; + } + get_consent().needs_prompt() +} + +/// The on-disk consent record. Additive fields only; `source` and +/// `prompted_mxc_version` are provenance for support/debugging and are never +/// transmitted anywhere. +#[cfg(target_os = "windows")] +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ConsentRecord { + #[serde(rename = "schemaVersion")] + schema_version: u32, + consent: String, + #[serde(default)] + source: String, + #[serde(rename = "promptedMxcVersion", default)] + prompted_mxc_version: String, + /// Unix seconds at the time of the last grant/revoke. Debug/support + /// provenance only — never used for gating — so a plain epoch integer is + /// enough; `#[serde(default)]` means older or foreign records missing + /// this field just read back as `0` rather than failing to parse. + #[serde(rename = "updatedAtEpoch", default)] + updated_at_epoch: u64, +} + +/// Returns the current, persisted telemetry consent state. +/// +/// Fail-closed: a missing file, an unreadable file, unparseable JSON, or an +/// unrecognized `schemaVersion` all resolve to [`ConsentState::Undetermined`] +/// — never to `Granted`. Always [`ConsentState::NotApplicable`] on +/// non-Windows platforms, without any filesystem access. +pub fn get_consent() -> ConsentState { + platform::read() +} + +/// Persists a new telemetry consent decision for the current Windows user. +/// +/// `source` is free-form provenance (e.g. `"prompt"`, `"settings-toggle"`, +/// `"cli"`) recorded alongside the decision for support/debugging; it is +/// never transmitted anywhere and never affects gating. +/// +/// Returns an error string suitable for CLI/log output. On non-Windows this +/// always fails with a descriptive "not applicable" error — MXC must not +/// silently accept a consent decision it can never act on. +pub fn set_consent(granted: bool, source: &str) -> Result<(), String> { + platform::write(granted, source) +} + +/// Current time as Unix seconds. Debug/support provenance only (see +/// [`ConsentRecord::updated_at_epoch`]) — a raw epoch avoids pulling in a +/// date-formatting dependency, or hand-rolling calendar math, just to stamp +/// a field nothing ever gates on. +#[cfg(target_os = "windows")] +fn now_epoch_seconds() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +// --------------------------------------------------------------------------- +// Windows implementation — real, persisted, per-user consent store. +// --------------------------------------------------------------------------- + +#[cfg(target_os = "windows")] +mod platform { + use super::{now_epoch_seconds, ConsentRecord, ConsentState, CONSENT_SCHEMA_VERSION}; + use std::fs; + use std::path::{Path, PathBuf}; + use std::time::Duration; + + /// Number of attempts for filesystem operations that can transiently fail + /// under Windows file-lock contention (AV real-time scanning, the search + /// indexer, or a concurrent `wxc-exec` grant/revoke in another process). + const IO_RETRY_ATTEMPTS: u32 = 5; + const IO_RETRY_DELAY: Duration = Duration::from_millis(20); + + /// `ERROR_SHARING_VIOLATION` — another handle holds the file with a + /// conflicting share mode (AV scanner, indexer, racing sibling process). + const ERROR_SHARING_VIOLATION: i32 = 32; + /// `ERROR_LOCK_VIOLATION` — a byte-range lock is held on the file. + const ERROR_LOCK_VIOLATION: i32 = 33; + + /// Resolves the real, per-user `%LocalAppData%` directory via the Windows + /// known-folder API (`SHGetKnownFolderPath`/`FOLDERID_LocalAppData`) + /// rather than trusting the `LOCALAPPDATA` *environment variable*. A + /// parent process launching `wxc-exec.exe` fully controls the child's + /// environment, so trusting `LOCALAPPDATA` directly would let it point + /// the consent store at an attacker-chosen directory and plant a fake + /// "granted" record — undermining the very consent MXC is supposed to + /// own. The known-folder API resolves the path registered for the + /// process's own user token, independent of environment state. + /// + /// `SHGetKnownFolderPath` is COM IPC plus a registry read, and — because + /// we bind it explicitly to the *process* token (see + /// [`resolve_known_folder_local_app_data`]) — the answer cannot change for + /// the lifetime of the process, so the result is memoized. A long-lived + /// host application querying consent through the SDK/FFI on every + /// operation should not pay that cost repeatedly. + fn known_folder_local_app_data() -> Option { + static CACHED: std::sync::OnceLock> = std::sync::OnceLock::new(); + CACHED + .get_or_init(resolve_known_folder_local_app_data) + .clone() + } + + /// Opens the current **process** token with `TOKEN_QUERY`. + /// + /// Passing `None` to `SHGetKnownFolderPath` would resolve against the + /// calling *thread's* token — the impersonated one, if the host has + /// impersonated another user. That would both invalidate the memoization + /// above and let an embedding host redirect the consent store simply by + /// impersonating, which is the same class of attack the known-folder API + /// is being used to prevent in the first place. Binding explicitly to the + /// process token keeps the answer a stable property of the process. + fn process_token() -> Option { + use windows::Win32::Foundation::HANDLE; + use windows::Win32::Security::TOKEN_QUERY; + use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + let mut token = HANDLE::default(); + // SAFETY: `GetCurrentProcess` returns a pseudo-handle that needs no + // release, and `token` is a valid out-pointer for the duration of the + // call. On success the returned handle is immediately wrapped in + // `OwnedHandle`, whose `Drop` closes it exactly once. + unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) }.ok()?; + Some(crate::process_util::OwnedHandle::new(token)) + } + + fn resolve_known_folder_local_app_data() -> Option { + use crate::string_util::CoTaskMemPWSTR; + use windows::Win32::UI::Shell::{ + FOLDERID_LocalAppData, SHGetKnownFolderPath, KF_FLAG_DEFAULT, + }; + + // SAFETY: `FOLDERID_LocalAppData` is a valid, static, well-known GUID + // constant. The token argument is this process's own token, so the + // result is independent of any thread impersonation the host may have + // in effect (see `process_token`); it stays alive for the duration of + // the call because `token` is still in scope. The returned `PWSTR` is + // COM-allocated and is immediately wrapped in `CoTaskMemPWSTR`, whose + // `Drop` frees it via `CoTaskMemFree` exactly once. + let token = process_token()?; + let pwstr = unsafe { + SHGetKnownFolderPath(&FOLDERID_LocalAppData, KF_FLAG_DEFAULT, Some(token.get())) + } + .ok()?; + let owned = CoTaskMemPWSTR::new(pwstr.0); + let resolved = owned.to_string_lossy(); + if resolved.is_empty() { + None + } else { + Some(PathBuf::from(resolved)) + } + } + + /// Test-only escape hatch so deterministic tests — this crate's own unit + /// tests, `mxc_ffi`'s Rust tests, and a local `dotnet test`/`npm test` run + /// against a debug-profile native binary — can redirect the consent store + /// to a throwaway temp directory. + /// + /// Active only under `cfg(test)` (this crate's own test harness, which is + /// how CI exercises it — CI runs `cargo test --release`, so gating on + /// `debug_assertions` alone would silently drop every consent test from + /// CI *and* let them read and overwrite the real store) or in a debug + /// build (which is what lets `mxc_ffi`'s cross-crate tests, where + /// `cfg(test)` does not apply to this crate, redirect it). + /// + /// Neither condition holds for a binary MXC ships: a release + /// `wxc-exec.exe` is not compiled with `--test` and has + /// `debug_assertions` off, so this branch and the env-var read backing it + /// are compiled out entirely, and a parent process has no way to redirect + /// the store. + #[cfg(any(test, debug_assertions))] + fn debug_local_app_data_override() -> Option { + std::env::var_os("MXC_TEST_LOCALAPPDATA_OVERRIDE").map(PathBuf::from) + } + + fn local_app_data_dir() -> Option { + #[cfg(any(test, debug_assertions))] + if let Some(over) = debug_local_app_data_override() { + return Some(over); + } + known_folder_local_app_data() + } + + /// Test-only accessor for [`known_folder_local_app_data`], which is + /// otherwise private to this module. + #[cfg(test)] + pub(super) fn known_folder_local_app_data_for_test() -> Option { + known_folder_local_app_data() + } + + /// Per-user consent store path: `\mxc\telemetry-consent.json`. + /// Per-user (not `%ProgramData%`) because consent is a personal choice — + /// multiple people sharing one machine each control their own, with no + /// elevation required to change it (mirrors `wxc-exec.exe` never + /// self-elevating; see `docs/host-prep.md`). + fn consent_file_path() -> Option { + local_app_data_dir().map(|dir| dir.join("mxc").join("telemetry-consent.json")) + } + + /// Whether an I/O error is plausibly a *transient* lock/share conflict + /// worth waiting out, as opposed to a settled answer. + /// + /// This distinction is load-bearing for startup latency, not just tidiness: + /// the overwhelmingly common state is "no consent file yet" (every fresh + /// install, and every user who has not yet been prompted), which surfaces + /// as `NotFound`. Retrying that would add `IO_RETRY_ATTEMPTS - 1` sleeps — + /// 80 ms — to the consent read on the critical path of *every* sandbox + /// launch, to re-confirm a result that cannot change. + fn is_transient_io_error(e: &std::io::Error) -> bool { + if matches!( + e.raw_os_error(), + Some(ERROR_SHARING_VIOLATION) | Some(ERROR_LOCK_VIOLATION) + ) { + return true; + } + matches!( + e.kind(), + std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::Interrupted + ) + } + + /// Retries `op` up to [`IO_RETRY_ATTEMPTS`] times with a short fixed + /// delay, to ride out transient Windows file-lock contention (AV + /// scanning, indexing, a racing sibling process) rather than failing the + /// very first time a lock is briefly held by someone else. + /// + /// Only [`is_transient_io_error`] failures are retried; anything else + /// (notably `NotFound`) is returned immediately. + fn with_io_retry(mut op: impl FnMut() -> std::io::Result) -> std::io::Result { + let mut attempt = 0; + loop { + match op() { + Ok(v) => return Ok(v), + Err(e) if attempt + 1 < IO_RETRY_ATTEMPTS && is_transient_io_error(&e) => { + attempt += 1; + std::thread::sleep(IO_RETRY_DELAY); + } + Err(e) => return Err(e), + } + } + } + + /// Test-only accessor for [`with_io_retry`], which is otherwise private + /// to this module. + #[cfg(test)] + pub(super) fn with_io_retry_for_test( + op: impl FnMut() -> std::io::Result, + ) -> std::io::Result { + with_io_retry(op) + } + + /// Test-only constructor for a synthetic transient error, so retry tests + /// don't have to guess which raw OS codes this module treats as transient. + #[cfg(test)] + pub(super) fn transient_io_error_for_test() -> std::io::Error { + std::io::Error::from_raw_os_error(ERROR_SHARING_VIOLATION) + } + + #[cfg(test)] + pub(super) const IO_RETRY_ATTEMPTS_FOR_TEST: u32 = IO_RETRY_ATTEMPTS; + + pub(super) fn read() -> ConsentState { + let Some(path) = consent_file_path() else { + return ConsentState::Undetermined; + }; + let Ok(data) = with_io_retry(|| fs::read_to_string(&path)) else { + return ConsentState::Undetermined; + }; + let Ok(record) = serde_json::from_str::(&data) else { + return ConsentState::Undetermined; + }; + // Fail closed on any schema we don't recognize, rather than guessing + // at forward/backward compatibility. + if record.schema_version != CONSENT_SCHEMA_VERSION { + return ConsentState::Undetermined; + } + match record.consent.as_str() { + "granted" => ConsentState::Granted, + "denied" => ConsentState::Denied, + _ => ConsentState::Undetermined, + } + } + + /// Best-effort cleanup of a leftover temp file; failures here are not + /// reported since the operation they were cleaning up after has already + /// failed (or, on the success path, this is a pure best-effort tidy-up). + fn remove_best_effort(path: &Path) { + let _ = fs::remove_file(path); + } + + pub(super) fn write(granted: bool, source: &str) -> Result<(), String> { + let path = consent_file_path().ok_or_else(|| { + "could not resolve %LocalAppData%; cannot persist telemetry consent".to_string() + })?; + let dir = path + .parent() + .ok_or_else(|| "invalid telemetry consent path".to_string())?; + fs::create_dir_all(dir).map_err(|e| format!("failed to create {}: {e}", dir.display()))?; + + let record = ConsentRecord { + schema_version: CONSENT_SCHEMA_VERSION, + consent: if granted { "granted" } else { "denied" }.to_string(), + source: source.to_string(), + prompted_mxc_version: crate::telemetry::version().to_string(), + updated_at_epoch: now_epoch_seconds(), + }; + let json = serde_json::to_string_pretty(&record) + .map_err(|e| format!("failed to serialize telemetry consent record: {e}"))?; + + // Atomic write: write to a *unique* temp file in the same directory + // (process id + a random suffix, so two concurrent `wxc-exec` + // grant/revoke invocations never share — and thus never race on — + // the same temp path), then rename over the real path. A crash + // mid-write never leaves a torn/corrupt file in place of a + // previously-valid one; if the rename itself fails, the temp file is + // removed rather than left behind as a leaked, orphaned artifact. + let unique = format!("{}-{:x}", std::process::id(), random_suffix()); + let tmp_path = path.with_extension(format!("json.{unique}.tmp")); + + if let Err(e) = with_io_retry(|| fs::write(&tmp_path, &json)) { + remove_best_effort(&tmp_path); + return Err(format!("failed to write {}: {e}", tmp_path.display())); + } + if let Err(e) = with_io_retry(|| fs::rename(&tmp_path, &path)) { + remove_best_effort(&tmp_path); + return Err(format!("failed to finalize {}: {e}", path.display())); + } + Ok(()) + } + + /// A cheap, non-cryptographic source of per-call uniqueness for the temp + /// filename. Only needs to avoid same-process, same-nanosecond + /// collisions between concurrent threads — not to be unguessable — so + /// the current time's subsecond component plus the thread id is enough. + fn random_suffix() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.subsec_nanos()) + .unwrap_or(0) as u64; + let tid = { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + std::thread::current().id().hash(&mut hasher); + hasher.finish() + }; + nanos ^ tid + } +} + +// --------------------------------------------------------------------------- +// Non-Windows stub — no file, no prompt, no pretend consent. +// --------------------------------------------------------------------------- + +#[cfg(not(target_os = "windows"))] +mod platform { + use super::ConsentState; + + pub(super) fn read() -> ConsentState { + ConsentState::NotApplicable + } + + pub(super) fn write(_granted: bool, _source: &str) -> Result<(), String> { + Err("telemetry is Windows-only; consent is not applicable on this platform".to_string()) + } +} + +// --------------------------------------------------------------------------- +// Test support — shared with `telemetry::mod`'s `is_enabled` tests so both +// modules can safely mutate the process-global consent-store override +// without racing each other under parallel test execution. +// +// This redirects `MXC_TEST_LOCALAPPDATA_OVERRIDE`, a debug-build-only escape +// hatch (see `platform::debug_local_app_data_override` above) — never the +// real `LOCALAPPDATA` variable, and never present at all in a release build. +// --------------------------------------------------------------------------- + +#[cfg(test)] +pub(crate) mod test_support { + use std::sync::{Mutex, MutexGuard}; + + /// `get_consent`/`set_consent` read the debug-only override, which is + /// process-global state. Guarded internally by [`LocalAppDataGuard::set`] + /// so a caller can never forget to hold it — see that type's doc comment. + /// `pub(crate)` only so the rare test that must mutate the override + /// directly (bypassing the guard, e.g. to test the "no override set" + /// fallback path) can still serialize against guard-holding tests. + pub(crate) static ENV_LOCK: Mutex<()> = Mutex::new(()); + + /// Redirects the consent store (Windows only) to a fresh temp directory + /// for the lifetime of the guard, restoring the previous value on drop. + /// Acquires and holds [`ENV_LOCK`] for the guard's entire lifetime, so + /// tests cannot accidentally construct the guard without the lock (the + /// two were previously separate, and a test could forget to pair them). + /// A no-op holder on non-Windows, where consent is `NotApplicable` + /// regardless — kept as a real (if inert) guard type so callers don't + /// need `#[cfg]` at every call site. + pub(crate) struct LocalAppDataGuard { + _lock: MutexGuard<'static, ()>, + #[cfg(target_os = "windows")] + previous: Option, + } + + impl LocalAppDataGuard { + #[cfg(target_os = "windows")] + pub(crate) fn set(path: &std::path::Path) -> Self { + let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let previous = std::env::var_os("MXC_TEST_LOCALAPPDATA_OVERRIDE"); + std::env::set_var("MXC_TEST_LOCALAPPDATA_OVERRIDE", path); + Self { + _lock: lock, + previous, + } + } + + #[cfg(not(target_os = "windows"))] + pub(crate) fn set(_path: &std::path::Path) -> Self { + let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + Self { _lock: lock } + } + } + + #[cfg(target_os = "windows")] + impl Drop for LocalAppDataGuard { + fn drop(&mut self) { + match &self.previous { + Some(v) => std::env::set_var("MXC_TEST_LOCALAPPDATA_OVERRIDE", v), + None => std::env::remove_var("MXC_TEST_LOCALAPPDATA_OVERRIDE"), + } + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::test_support::LocalAppDataGuard; + use super::*; + + #[test] + fn consent_state_as_str_is_stable() { + assert_eq!(ConsentState::Granted.as_str(), "granted"); + assert_eq!(ConsentState::Denied.as_str(), "denied"); + assert_eq!(ConsentState::Undetermined.as_str(), "undetermined"); + assert_eq!(ConsentState::NotApplicable.as_str(), "not-applicable"); + } + + #[test] + fn only_granted_allows_collection() { + assert!(ConsentState::Granted.allows_collection()); + assert!(!ConsentState::Denied.allows_collection()); + assert!(!ConsentState::Undetermined.allows_collection()); + assert!(!ConsentState::NotApplicable.allows_collection()); + } + + #[test] + fn only_undetermined_needs_prompt() { + assert!(ConsentState::Undetermined.needs_prompt()); + assert!(!ConsentState::Granted.needs_prompt()); + assert!(!ConsentState::Denied.needs_prompt()); + // NotApplicable means "not Windows", where MXC collects nothing and + // therefore must never ask. Prompting here would be a privacy defect, + // not merely a redundant dialog. + assert!(!ConsentState::NotApplicable.needs_prompt()); + } + + #[cfg(target_os = "windows")] + #[test] + fn now_epoch_seconds_is_plausible() { + // Sanity bound: some time after this test was written, and not an + // absurd far-future value from an overflow/unit bug. + let secs = now_epoch_seconds(); + assert!(secs > 1_700_000_000, "epoch seconds too small: {secs}"); + assert!(secs < 4_000_000_000, "epoch seconds too large: {secs}"); + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn non_windows_is_always_not_applicable_and_write_fails() { + let _guard = LocalAppDataGuard::set(std::path::Path::new("unused")); + assert_eq!(get_consent(), ConsentState::NotApplicable); + assert!(set_consent(true, "cli").is_err()); + assert!(set_consent(false, "cli").is_err()); + assert!( + !needs_consent_prompt(), + "must never ask for consent where nothing is collected" + ); + } + + #[cfg(target_os = "windows")] + mod windows_tests { + use super::LocalAppDataGuard; + use crate::telemetry::consent::{ + get_consent, needs_consent_prompt, set_consent, ConsentState, + }; + + #[test] + fn needs_consent_prompt_tracks_the_store() { + let tmp = tempfile::tempdir().unwrap(); + // Also isolates the policy key: `needs_consent_prompt` consults it, + // so without this the test reads the real machine policy and fails + // on an administratively managed device. + let _env = crate::telemetry::test_support::TelemetryTestEnv::new(tmp.path()); + assert!(needs_consent_prompt(), "fresh store must prompt"); + set_consent(false, "prompt").unwrap(); + assert!( + !needs_consent_prompt(), + "a recorded denial must not re-prompt" + ); + set_consent(true, "settings-toggle").unwrap(); + assert!( + !needs_consent_prompt(), + "a recorded grant must not re-prompt" + ); + } + + #[test] + fn fresh_store_is_undetermined() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = LocalAppDataGuard::set(tmp.path()); + assert_eq!(get_consent(), ConsentState::Undetermined); + } + + #[test] + fn grant_then_read_round_trips() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = LocalAppDataGuard::set(tmp.path()); + set_consent(true, "cli").expect("grant should succeed"); + assert_eq!(get_consent(), ConsentState::Granted); + } + + #[test] + fn deny_then_read_round_trips() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = LocalAppDataGuard::set(tmp.path()); + set_consent(false, "cli").expect("deny should succeed"); + assert_eq!(get_consent(), ConsentState::Denied); + } + + #[test] + fn consent_can_be_flipped_repeatedly() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = LocalAppDataGuard::set(tmp.path()); + set_consent(true, "prompt").unwrap(); + assert_eq!(get_consent(), ConsentState::Granted); + set_consent(false, "settings-toggle").unwrap(); + assert_eq!(get_consent(), ConsentState::Denied); + set_consent(true, "settings-toggle").unwrap(); + assert_eq!(get_consent(), ConsentState::Granted); + } + + #[test] + fn corrupt_file_is_undetermined_not_granted() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = LocalAppDataGuard::set(tmp.path()); + let dir = tmp.path().join("mxc"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("telemetry-consent.json"), "not json at all").unwrap(); + assert_eq!(get_consent(), ConsentState::Undetermined); + } + + #[test] + fn unknown_schema_version_is_undetermined() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = LocalAppDataGuard::set(tmp.path()); + let dir = tmp.path().join("mxc"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("telemetry-consent.json"), + r#"{"schemaVersion":999,"consent":"granted"}"#, + ) + .unwrap(); + assert_eq!(get_consent(), ConsentState::Undetermined); + } + + #[test] + fn concurrent_grant_revoke_never_corrupts_the_store() { + // Regression test for the write-race finding: many threads + // hammering set_consent() against the same redirected store + // concurrently (the override env var is process-global, so + // every spawned thread inherits the same redirected path from + // the outer guard) must never leave a torn/missing file behind; + // the final state must be a fully-formed, parseable record. + let tmp = tempfile::tempdir().unwrap(); + let _guard = LocalAppDataGuard::set(tmp.path()); + + // Every writer must *succeed*, not merely avoid corrupting the + // file. Asserting only on the final file's parseability would + // still pass if 15 of 16 writers lost a temp-name or rename + // race, which is precisely the failure this fix targets. + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| scope.spawn(move || set_consent(i % 2 == 0, "cli"))) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + for (i, result) in results.iter().enumerate() { + assert!(result.is_ok(), "concurrent writer {i} failed: {result:?}"); + } + + // Whatever the last writer's outcome was, the store must be + // Granted or Denied — never Undetermined (which would mean a + // corrupt/missing file from a torn write). + let state = get_consent(); + assert!(matches!( + state, + ConsentState::Granted | ConsentState::Denied + )); + } + + /// The `updatedAtUtc: String` → `updatedAtEpoch: u64` rename claims + /// backward compatibility via `#[serde(default)]`. That claim is + /// load-bearing (a user who already granted consent must not be + /// silently reset to Undetermined and re-prompted), so lock it in + /// against a verbatim pre-rename record. + #[test] + fn pre_rename_record_still_reads_back_its_consent_state() { + for (stored, expected) in [ + ("granted", ConsentState::Granted), + ("denied", ConsentState::Denied), + ] { + let tmp = tempfile::tempdir().unwrap(); + let _guard = LocalAppDataGuard::set(tmp.path()); + let dir = tmp.path().join("mxc"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("telemetry-consent.json"), + format!( + r#"{{"schemaVersion":1,"consent":"{stored}","source":"prompt","promptedMxcVersion":"0.7.0","updatedAtUtc":"2026-01-02T03:04:05Z"}}"# + ), + ) + .unwrap(); + assert_eq!(get_consent(), expected, "old-format {stored} record"); + } + } + + /// `with_io_retry` must ride out transient lock contention but must + /// not sleep through a settled answer. The `NotFound` case is the + /// startup-latency one: it is the state of every machine that has + /// never recorded a decision, on the critical path of every launch. + #[test] + fn io_retry_retries_transient_errors_then_succeeds() { + use crate::telemetry::consent::platform; + let mut calls = 0; + let result = platform::with_io_retry_for_test(|| { + calls += 1; + if calls < 3 { + Err(platform::transient_io_error_for_test()) + } else { + Ok(calls) + } + }); + assert_eq!(result.unwrap(), 3); + assert_eq!(calls, 3); + } + + #[test] + fn io_retry_gives_up_after_the_attempt_budget() { + use crate::telemetry::consent::platform; + let mut calls = 0; + let result = platform::with_io_retry_for_test(|| { + calls += 1; + Err::<(), _>(platform::transient_io_error_for_test()) + }); + assert!(result.is_err()); + assert_eq!(calls, platform::IO_RETRY_ATTEMPTS_FOR_TEST); + } + + #[test] + fn io_retry_does_not_retry_not_found() { + use crate::telemetry::consent::platform; + let mut calls = 0; + let started = std::time::Instant::now(); + let result = platform::with_io_retry_for_test(|| { + calls += 1; + Err::<(), _>(std::io::Error::new( + std::io::ErrorKind::NotFound, + "no consent file", + )) + }); + assert!(result.is_err()); + assert_eq!(calls, 1, "NotFound must not be retried"); + assert!( + started.elapsed() < std::time::Duration::from_millis(20), + "NotFound must not sleep on the retry delay" + ); + } + + /// The whole point of the above: reading a fresh (nonexistent) store + /// is the common case and must be effectively instant. + #[test] + fn fresh_store_read_does_not_sleep() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = LocalAppDataGuard::set(tmp.path()); + let started = std::time::Instant::now(); + assert_eq!(get_consent(), ConsentState::Undetermined); + assert!( + started.elapsed() < std::time::Duration::from_millis(40), + "fresh-store read took {:?}; the retry loop is sleeping on NotFound again", + started.elapsed() + ); + } + + #[test] + fn missing_local_app_data_resolution_falls_back_to_real_known_folder() { + // With no debug override set, the real per-user known-folder + // path must still resolve (it always exists on a real Windows + // profile) — this is exactly the fallback the security fix + // relies on, so exercise it without touching the real consent + // file itself. Locks ENV_LOCK directly (rather than via + // LocalAppDataGuard) since this test removes the override + // entirely instead of redirecting it. + let _lock = super::super::test_support::ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let previous = std::env::var_os("MXC_TEST_LOCALAPPDATA_OVERRIDE"); + std::env::remove_var("MXC_TEST_LOCALAPPDATA_OVERRIDE"); + let resolved = + crate::telemetry::consent::platform::known_folder_local_app_data_for_test(); + if let Some(v) = previous { + std::env::set_var("MXC_TEST_LOCALAPPDATA_OVERRIDE", v); + } + assert!( + resolved.is_some(), + "known-folder API should resolve a real path" + ); + } + } +} diff --git a/src/core/wxc_common/src/telemetry/consent_cli.rs b/src/core/wxc_common/src/telemetry/consent_cli.rs new file mode 100644 index 000000000..7ab603688 --- /dev/null +++ b/src/core/wxc_common/src/telemetry/consent_cli.rs @@ -0,0 +1,427 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Shared `--telemetry-consent-{status,grant,revoke}` CLI handling. +//! +//! All three executors (`wxc-exec`, `lxc-exec`, `mxc-exec-mac`) expose an +//! identical consent CLI surface for parity — a host application driving any +//! of them from any platform sees the same flags and the same JSON status +//! contract — but only `wxc-exec` actually persists anything; the others +//! resolve through [`super::consent`]'s non-Windows stub, which always +//! reports [`super::ConsentState::NotApplicable`] and refuses writes. This +//! module is the **single** implementation of that shared behavior so the +//! three executors' `main.rs` files delegate instead of each re-implementing +//! (and risking drifting) the same fast path. See +//! `docs/telemetry/telemetry-consent-design.md`. + +use serde::Serialize; + +use super::{consent, policy}; + +/// The subset of the executor's parsed CLI flags relevant to telemetry +/// consent administration. Deliberately primitive (not a `clap`-derived +/// type) so this module has no dependency on any one executor's `Cli` struct. +#[derive(Debug, Clone, Copy)] +pub struct ConsentCliFlags<'a> { + /// `--telemetry-consent-status` + pub status: bool, + /// `--telemetry-consent-grant` + pub grant: bool, + /// `--telemetry-consent-revoke` + pub revoke: bool, + /// `--telemetry-consent-source`; defaults to `"cli"` when absent. + pub source: Option<&'a str>, +} + +/// Wire shape for the one-line JSON status the CLI prints, e.g. +/// `{"consent":"granted","needsPrompt":false,"policy":"unrestricted"}`. Kept +/// as a real (de)serializable type — rather than hand-built string +/// interpolation — so the JSON is always well-formed and any future additive +/// field goes through `serde`. +/// +/// `needsPrompt` is carried explicitly, rather than left for each SDK to +/// derive from `consent`, so that the "should the host ask the user?" policy +/// has exactly one implementation ([`consent::needs_consent_prompt`]) across +/// every language binding. +/// +/// `policy` reports the administrative ceiling ([`policy::PolicyState`]) so a +/// host can distinguish "the user hasn't opted in" from "an administrator has +/// disabled this" and explain the difference, rather than rendering an inert +/// toggle. It is reported independently of `consent` because the two are +/// genuinely independent: a user's recorded grant is preserved verbatim even +/// while policy suppresses collection, so relaxing the policy later restores +/// the user's actual choice instead of silently re-prompting. +#[derive(Serialize)] +struct ConsentStatusResponse { + consent: &'static str, + #[serde(rename = "needsPrompt")] + needs_prompt: bool, + policy: &'static str, +} + +/// What the caller should do after [`handle_consent_flags`] handled one of +/// the consent flags: print these lines and terminate with `exit_code`. +/// +/// Returned as data rather than acted on here so `wxc_common` — the +/// cross-platform foundation crate — never owns process lifetime; the thin +/// executor binaries do the exiting, exactly as they do for every other CLI +/// fast path. It also makes every branch below assertable in a unit test +/// instead of terminating the test runner. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConsentCliOutcome { + /// Line to print to stdout, if any (the JSON status contract). + pub stdout: Option, + /// Line to print to stderr, if any. + pub stderr: Option, + /// Process exit code: `0` on success, `64` (`EX_USAGE`) for mutually + /// exclusive flags, `1` for a failed write or serialization. + pub exit_code: i32, +} + +impl ConsentCliOutcome { + /// Prints the outcome's stdout/stderr lines and returns the exit code the + /// caller should terminate with. + pub fn emit(&self) -> i32 { + if let Some(out) = &self.stdout { + println!("{out}"); + } + if let Some(err) = &self.stderr { + eprintln!("{err}"); + } + self.exit_code + } + + fn failure(message: String, exit_code: i32) -> Self { + Self { + stdout: None, + stderr: Some(message), + exit_code, + } + } +} + +/// Handles the `--telemetry-consent-{status,grant,revoke}` fast path shared +/// by all three executors. +/// +/// Returns `Some(outcome)` if one of the flags was set — the caller should +/// [`ConsentCliOutcome::emit`] it and exit immediately without spawning a +/// sandbox or touching config parsing — or `None` if none were passed and +/// normal execution should proceed. +/// +/// Windows-only in effect: [`super::consent`] compiles a non-Windows stub +/// that always reports `NotApplicable` and refuses to persist a decision, so +/// `--telemetry-consent-grant`/`-revoke` fail with a clear error on `lxc-exec` +/// / `mxc-exec-mac` rather than silently pretending to accept consent MXC +/// can never act on. +pub fn handle_consent_flags(flags: &ConsentCliFlags<'_>) -> Option { + if !(flags.status || flags.grant || flags.revoke) { + return None; + } + + if flags.grant && flags.revoke { + return Some(ConsentCliOutcome::failure( + "Error: --telemetry-consent-grant and --telemetry-consent-revoke are mutually exclusive" + .to_string(), + // EX_USAGE, matching the convention for a malformed command line. + 64, + )); + } + + if flags.grant || flags.revoke { + let source = flags.source.unwrap_or("cli"); + if let Err(e) = consent::set_consent(flags.grant, source) { + return Some(ConsentCliOutcome::failure(format!("Error: {e}"), 1)); + } + } + + // --telemetry-consent-status (or the post-grant/-revoke confirmation) + // always prints the resulting state so callers get a single, uniform + // JSON contract regardless of which flag was passed. + // + // Each underlying state is read exactly once and `needs_prompt` is derived + // from those same two values, rather than calling + // `consent::needs_consent_prompt()` (which would re-read both). Consumers + // treat this response as one snapshot, so a concurrent grant/revoke or + // policy change must not be able to produce a self-contradictory payload + // such as `policy:"blocked"` together with `needsPrompt:true`. + let consent_state = consent::get_consent(); + let policy_state = policy::get_policy(); + let response = ConsentStatusResponse { + consent: consent_state.as_str(), + needs_prompt: policy_state.allows_collection() && consent_state.needs_prompt(), + policy: policy_state.as_str(), + }; + match serde_json::to_string(&response) { + Ok(json) => Some(ConsentCliOutcome { + stdout: Some(json), + stderr: None, + exit_code: 0, + }), + Err(e) => Some(ConsentCliOutcome::failure( + // Serialization of two static strings cannot realistically fail, + // but fail loudly rather than silently print nothing if it does. + format!("Error: failed to serialize telemetry consent status: {e}"), + 1, + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn flags(status: bool, grant: bool, revoke: bool) -> ConsentCliFlags<'static> { + ConsentCliFlags { + status, + grant, + revoke, + source: None, + } + } + + #[test] + fn no_flags_is_a_noop() { + assert!(handle_consent_flags(&flags(false, false, false)).is_none()); + } + + #[test] + fn status_response_serializes_as_expected_json() { + let response = ConsentStatusResponse { + consent: "granted", + needs_prompt: false, + policy: "unrestricted", + }; + assert_eq!( + serde_json::to_string(&response).unwrap(), + r#"{"consent":"granted","needsPrompt":false,"policy":"unrestricted"}"# + ); + } + + #[test] + fn status_response_field_order_is_stable() { + // The SDKs parse this by field name, but the PowerShell smoke test + // compares the whole string, so pin the emitted order. + let response = ConsentStatusResponse { + consent: "undetermined", + needs_prompt: true, + policy: "unrestricted", + }; + assert_eq!( + serde_json::to_string(&response).unwrap(), + r#"{"consent":"undetermined","needsPrompt":true,"policy":"unrestricted"}"# + ); + } + + /// An administrative denial must be reported alongside the user's own + /// (unchanged) decision, and must suppress the prompt — a host that asked + /// for consent MXC would then ignore would be a dark pattern. + #[test] + fn blocked_policy_serializes_with_prompt_suppressed() { + let response = ConsentStatusResponse { + consent: "granted", + needs_prompt: false, + policy: "blocked", + }; + assert_eq!( + serde_json::to_string(&response).unwrap(), + r#"{"consent":"granted","needsPrompt":false,"policy":"blocked"}"# + ); + } + + /// Previously unreachable in-process: this branch called + /// `std::process::exit(64)` and would have killed the test runner. + #[test] + fn grant_and_revoke_together_is_a_usage_error() { + let outcome = handle_consent_flags(&flags(false, true, true)).expect("handled"); + assert_eq!(outcome.exit_code, 64); + assert_eq!(outcome.stdout, None); + assert!(outcome + .stderr + .as_deref() + .unwrap() + .contains("mutually exclusive")); + } + + /// The non-Windows contract every executor must honor: status is a + /// successful `not-applicable` report, and grant/revoke are refused + /// rather than silently accepted. MXC must never offer — or appear to + /// record — consent on a platform where it cannot collect telemetry. + /// + /// Gated to non-Windows (rather than merged into the Windows tests) + /// because it asserts the *stub* behavior; without it, Linux/macOS CI + /// would run no test at all over this shared handler. + #[cfg(not(target_os = "windows"))] + mod non_windows_tests { + use super::*; + + #[test] + fn status_reports_not_applicable_and_succeeds() { + let outcome = handle_consent_flags(&flags(true, false, false)).expect("handled"); + assert_eq!(outcome.exit_code, 0); + assert_eq!( + outcome.stdout.as_deref(), + Some( + r#"{"consent":"not-applicable","needsPrompt":false,"policy":"not-applicable"}"# + ) + ); + assert_eq!(outcome.stderr, None); + } + + #[test] + fn grant_is_refused() { + let outcome = handle_consent_flags(&flags(false, true, false)).expect("handled"); + assert_eq!(outcome.exit_code, 1); + assert_eq!(outcome.stdout, None); + assert!(outcome.stderr.as_deref().unwrap().contains("Windows-only")); + } + + #[test] + fn revoke_is_refused() { + let outcome = handle_consent_flags(&flags(false, false, true)).expect("handled"); + assert_eq!(outcome.exit_code, 1); + assert_eq!(outcome.stdout, None); + assert!(outcome.stderr.as_deref().unwrap().contains("Windows-only")); + } + } + + /// End-to-end coverage of the `handle_consent_flags` paths (grant, + /// revoke, status, and a forced write failure) against an isolated + /// consent store — this is the same fast path all three executors + /// (`wxc-exec`, `lxc-exec`, `mxc-exec-mac`) delegate to, so exercising it + /// here covers all three, not just `wxc-exec` (which previously had the + /// only CLI-level smoke test). + #[cfg(target_os = "windows")] + mod windows_tests { + use super::*; + use crate::telemetry::test_support::TelemetryTestEnv; + + /// Isolates both process-global test hooks: the policy key (so a real + /// machine policy on the dev box cannot change the expected output) + /// and the consent store. See [`TelemetryTestEnv`] for why acquiring + /// them together, in one place, is what keeps the pair deadlock-free. + fn isolate(tmp: &std::path::Path) -> TelemetryTestEnv { + TelemetryTestEnv::new(tmp) + } + + #[test] + fn grant_flag_persists_and_reports_granted() { + let tmp = tempfile::tempdir().unwrap(); + let _guards = isolate(tmp.path()); + + let outcome = handle_consent_flags(&ConsentCliFlags { + status: false, + grant: true, + revoke: false, + source: Some("prompt"), + }) + .expect("handled"); + assert_eq!(outcome.exit_code, 0); + assert_eq!( + outcome.stdout.as_deref(), + Some(r#"{"consent":"granted","needsPrompt":false,"policy":"unrestricted"}"#) + ); + assert_eq!(consent::get_consent().as_str(), "granted"); + } + + #[test] + fn revoke_flag_persists_and_reports_denied() { + let tmp = tempfile::tempdir().unwrap(); + let _guards = isolate(tmp.path()); + + let outcome = handle_consent_flags(&ConsentCliFlags { + status: false, + grant: false, + revoke: true, + source: Some("settings-toggle"), + }) + .expect("handled"); + assert_eq!(outcome.exit_code, 0); + assert_eq!( + outcome.stdout.as_deref(), + Some(r#"{"consent":"denied","needsPrompt":false,"policy":"unrestricted"}"#) + ); + assert_eq!(consent::get_consent().as_str(), "denied"); + } + + #[test] + fn status_flag_reports_current_state_without_mutating_it() { + let tmp = tempfile::tempdir().unwrap(); + let _guards = isolate(tmp.path()); + + let outcome = handle_consent_flags(&flags(true, false, false)).expect("handled"); + assert_eq!(outcome.exit_code, 0); + assert_eq!( + outcome.stdout.as_deref(), + Some(r#"{"consent":"undetermined","needsPrompt":true,"policy":"unrestricted"}"#) + ); + assert_eq!(consent::get_consent().as_str(), "undetermined"); + } + + /// Under an administrative denial the status must still report the + /// user's own recorded decision truthfully — the grant is preserved, + /// not erased — while advertising the block and suppressing the + /// prompt. + #[test] + fn blocked_policy_is_reported_and_suppresses_the_prompt() { + let tmp = tempfile::tempdir().unwrap(); + let env = isolate(tmp.path()); + env.set_policy_value(0); + + let outcome = handle_consent_flags(&flags(true, false, false)).expect("handled"); + assert_eq!(outcome.exit_code, 0); + assert_eq!( + outcome.stdout.as_deref(), + Some(r#"{"consent":"undetermined","needsPrompt":false,"policy":"blocked"}"#) + ); + } + + /// A user may still record a decision while policy blocks collection; + /// it is honoured if the administrator later relaxes the policy. What + /// must not happen is the grant being treated as collectable. + #[test] + fn grant_is_still_recorded_under_a_blocking_policy() { + let tmp = tempfile::tempdir().unwrap(); + let env = isolate(tmp.path()); + env.set_policy_value(0); + + let outcome = handle_consent_flags(&ConsentCliFlags { + status: false, + grant: true, + revoke: false, + source: Some("cli"), + }) + .expect("handled"); + assert_eq!(outcome.exit_code, 0); + assert_eq!( + outcome.stdout.as_deref(), + Some(r#"{"consent":"granted","needsPrompt":false,"policy":"blocked"}"#) + ); + assert!(!crate::telemetry::is_enabled( + &crate::models::TelemetryConfig::default() + )); + } + + /// Previously unreachable in-process: this branch called + /// `std::process::exit(1)`. A regular *file* named `mxc` where the + /// store's parent directory belongs makes `create_dir_all` fail, so + /// the write path errors out deterministically without needing + /// permissions games. + #[test] + fn write_failure_reports_error_and_nonzero_exit() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("mxc"), b"not a directory").unwrap(); + let _guards = isolate(tmp.path()); + + let outcome = handle_consent_flags(&ConsentCliFlags { + status: false, + grant: true, + revoke: false, + source: Some("cli"), + }) + .expect("handled"); + assert_eq!(outcome.exit_code, 1); + assert_eq!(outcome.stdout, None); + assert!(outcome.stderr.as_deref().unwrap().starts_with("Error: ")); + } + } +} diff --git a/src/core/wxc_common/src/telemetry/mod.rs b/src/core/wxc_common/src/telemetry/mod.rs index 15a85d4eb..bf242e24e 100644 --- a/src/core/wxc_common/src/telemetry/mod.rs +++ b/src/core/wxc_common/src/telemetry/mod.rs @@ -11,8 +11,11 @@ //! //! On non-Windows platforms, all telemetry functions are no-ops. +pub mod consent; +pub mod consent_cli; pub mod correlation_vector; pub mod events; +pub mod policy; use std::time::Duration; @@ -24,7 +27,9 @@ use crate::models::{ContainmentBackend, FailurePhase, ScriptResponse, TelemetryC use crate::mxc_error::{MxcError, MxcErrorCode}; use crate::state_aware_dispatch::DispatchOutcome; +pub use consent::ConsentState; pub use events::{log_error, log_execution, ExecutionEvent, FailureReason, TelemetryContext}; +pub use policy::PolicyState; /// Conventional process exit code for a Rust panic/abort. Used as the reported /// `exit_code` on crash telemetry, since the panicking process has not (and @@ -140,14 +145,41 @@ pub fn version() -> &'static str { /// Resolve whether telemetry is enabled for this invocation. /// -/// Resolution: -/// - `experimental.telemetry.enabled` in JSON config — explicit override. -/// - Default: off (telemetry requires explicit opt-in). +/// Resolution order (see `docs/telemetry/telemetry-consent-design.md`): +/// 1. Persisted, per-user consent ([`consent::get_consent`]) is the gate. +/// Without [`consent::ConsentState::Granted`], telemetry never activates +/// — this is `NotApplicable` by construction on every non-Windows +/// platform, so telemetry can never activate there either. +/// 2. Administrative policy ([`policy::get_policy`]) is a *ceiling*: an +/// administrator can deny telemetry machine-wide via MDM/Intune or Group +/// Policy, and that denial overrides an existing user grant. It is never a +/// grant in the other direction — no policy value can enable telemetry for +/// a user who has not consented. +/// 3. `experimental.telemetry.enabled` in the JSON config is an explicit +/// per-invocation opt-in that can only ever subtract further: telemetry +/// requires `Some(true)`, an explicit `Some(false)` always forces it off +/// (useful for CI, support repros, or policy), and omitting the field +/// leaves it off. `Some(true)` can never *bypass* consent — a config +/// author cannot turn telemetry on for someone who hasn't agreed to it. /// -/// Note: Consent is the SDK consumer's responsibility. MXC does not implement -/// consent prompts or persistent consent storage. +/// Every term is a conjunct, and each one alone can only ever subtract. MXC +/// owns consent end-to-end (persistence, and the CLI/SDK toggle surfaces); it +/// does not merely trust a per-request flag from the caller. +/// +/// This function is *necessary but not sufficient*: telemetry is still an +/// experimental feature, so the executors only reach [`init`] at all when +/// `--experimental` was passed **and** the request carries an +/// `experimental.telemetry` block. A consenting user who omits that block — or +/// who supplies the block without `enabled: true` — gets no telemetry; the +/// gates compose, and every one of them can only subtract. pub fn is_enabled(config: &TelemetryConfig) -> bool { - config.enabled.unwrap_or(false) + // Fail closed: only an explicit `true` opts in. `None` is not "no + // opinion" — an author who omits the field gets no telemetry, which is + // what the published schema promises. + if config.enabled != Some(true) { + return false; + } + policy::get_policy().allows_collection() && consent::get_consent().allows_collection() } /// Initialize the TraceLogging ETW provider. @@ -650,8 +682,55 @@ pub fn emit_state_aware( shutdown(); } +#[cfg(test)] +pub(crate) mod test_support { + use super::consent::test_support::LocalAppDataGuard; + use super::policy::test_support::PolicyKeyGuard; + + /// A fully isolated telemetry environment: both the administrative policy + /// key and the user consent store are redirected to throwaway, per-test + /// locations. + /// + /// This is the **only** supported way to hold both guards at once. They + /// protect separate process-global mutexes, so acquiring them in + /// inconsistent orders across tests would deadlock under `cargo test`'s + /// multithreaded runner. Constructing them here — policy first, then + /// consent — is what establishes the total order that makes the pair + /// deadlock-free, and a caller cannot get it wrong because a caller never + /// sees the individual acquisitions. + /// + /// Every test that reaches [`super::is_enabled`], + /// [`super::consent::needs_consent_prompt`], or [`super::policy::get_policy`] + /// must hold this — *including* tests that only care about consent. + /// Otherwise they read the real machine policy and fail on an + /// administratively managed device. + pub(crate) struct TelemetryTestEnv { + // Fields drop in declaration order, so consent is released before + // policy: the exact reverse of the acquisition order below. + _consent: LocalAppDataGuard, + policy: PolicyKeyGuard, + } + + impl TelemetryTestEnv { + /// Redirects the consent store to `store` and the policy key to a + /// fresh, empty one (i.e. an unmanaged machine). + pub(crate) fn new(store: &std::path::Path) -> Self { + let policy = PolicyKeyGuard::new(); + let _consent = LocalAppDataGuard::set(store); + Self { _consent, policy } + } + + /// Sets the administrative `AllowTelemetry` policy value. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + pub(crate) fn set_policy_value(&self, value: u32) { + self.policy.set_value(value); + } + } +} + #[cfg(test)] mod tests { + use super::test_support::TelemetryTestEnv; use super::*; /// Serializes tests that touch the process-global emit slot / context @@ -661,15 +740,114 @@ mod tests { static TEST_LOCK: Mutex<()> = Mutex::new(()); #[test] - fn is_enabled_explicit_true() { + fn is_enabled_explicit_true_alone_does_not_bypass_consent() { + // Consent isolated to a fresh, empty store (Undetermined) — an + // explicit `enabled: true` in the config must not be able to turn + // telemetry on for someone who has not granted consent. + let tmp = tempfile::tempdir().unwrap(); + let _env = TelemetryTestEnv::new(tmp.path()); let config = TelemetryConfig { enabled: Some(true), }; - assert!(is_enabled(&config)); + assert!(!is_enabled(&config)); + } + + #[cfg(target_os = "windows")] + #[test] + fn is_enabled_true_when_consent_granted() { + let tmp = tempfile::tempdir().unwrap(); + let _env = TelemetryTestEnv::new(tmp.path()); + consent::set_consent(true, "cli").unwrap(); + // An explicit opt-in is required in addition to consent. + assert!(is_enabled(&TelemetryConfig { + enabled: Some(true) + })); + } + + /// An administrative denial overrides an explicit user grant. This is the + /// MDM/Intune ceiling: policy can only ever subtract. + #[cfg(target_os = "windows")] + #[test] + fn is_enabled_false_when_policy_blocks_despite_consent() { + let tmp = tempfile::tempdir().unwrap(); + let env = TelemetryTestEnv::new(tmp.path()); + consent::set_consent(true, "cli").unwrap(); + env.set_policy_value(0); + + assert!(!is_enabled(&TelemetryConfig { + enabled: Some(true) + })); + } + + /// The converse, and the load-bearing privacy invariant: a permissive + /// administrative policy is *not* consent. An admin who allows telemetry + /// has not decided on the user's behalf. + #[cfg(target_os = "windows")] + #[test] + fn is_enabled_false_when_policy_allows_but_consent_is_absent() { + let tmp = tempfile::tempdir().unwrap(); + let env = TelemetryTestEnv::new(tmp.path()); + env.set_policy_value(3); + + assert_eq!(policy::get_policy(), policy::PolicyState::Allowed); + assert!(!is_enabled(&TelemetryConfig { + enabled: Some(true) + })); + } + + /// Explicit user denial must beat every policy state — including a + /// permissive one. Completes the consent × policy matrix. + #[cfg(target_os = "windows")] + #[test] + fn is_enabled_false_when_consent_denied_under_every_policy() { + for policy_value in [None, Some(0u32), Some(1), Some(3)] { + let tmp = tempfile::tempdir().unwrap(); + let env = TelemetryTestEnv::new(tmp.path()); + consent::set_consent(false, "cli").unwrap(); + if let Some(value) = policy_value { + env.set_policy_value(value); + } + + assert!( + !is_enabled(&TelemetryConfig { + enabled: Some(true) + }), + "denied consent must win over explicit enable under policy {policy_value:?}" + ); + } + } + + /// The policy is a ceiling, never a grant: no policy value may enable + /// telemetry for a user who has never recorded a decision. `Denied` is + /// covered above; this covers the fresh-machine `Undetermined` case, which + /// is the one a permissive policy could plausibly be mistaken for a grant. + #[cfg(target_os = "windows")] + #[test] + fn is_enabled_false_when_consent_undetermined_under_every_policy() { + for policy_value in [None, Some(0u32), Some(1), Some(3)] { + let tmp = tempfile::tempdir().unwrap(); + let env = TelemetryTestEnv::new(tmp.path()); + if let Some(value) = policy_value { + env.set_policy_value(value); + } + + assert_eq!(consent::get_consent(), consent::ConsentState::Undetermined); + assert!( + !is_enabled(&TelemetryConfig { + enabled: Some(true) + }), + "undetermined consent must block an explicit enable under policy {policy_value:?}" + ); + } } #[test] fn is_enabled_explicit_false() { + // The kill switch wins even when consent has been granted. + let tmp = tempfile::tempdir().unwrap(); + let _env = TelemetryTestEnv::new(tmp.path()); + #[cfg(target_os = "windows")] + consent::set_consent(true, "cli").unwrap(); let config = TelemetryConfig { enabled: Some(false), }; @@ -678,10 +856,30 @@ mod tests { #[test] fn is_enabled_default_off() { + let tmp = tempfile::tempdir().unwrap(); + let _env = TelemetryTestEnv::new(tmp.path()); let config = TelemetryConfig::default(); assert!(!is_enabled(&config)); } + /// The load-bearing half of "omitted = off". Without granting consent + /// first this test would pass for the wrong reason — a fresh store is + /// `Undetermined`, which disables telemetry on its own — and would keep + /// passing if omission silently started meaning "defer to consent". + #[cfg(target_os = "windows")] + #[test] + fn is_enabled_false_when_enabled_omitted_despite_consent_and_permissive_policy() { + let tmp = tempfile::tempdir().unwrap(); + let env = TelemetryTestEnv::new(tmp.path()); + consent::set_consent(true, "cli").unwrap(); + env.set_policy_value(3); + + assert_eq!(consent::get_consent(), consent::ConsentState::Granted); + assert_eq!(policy::get_policy(), policy::PolicyState::Allowed); + // Every other gate is open; only the omitted opt-in keeps it off. + assert!(!is_enabled(&TelemetryConfig { enabled: None })); + } + #[test] fn version_is_not_empty() { assert!(!version().is_empty()); diff --git a/src/core/wxc_common/src/telemetry/policy.rs b/src/core/wxc_common/src/telemetry/policy.rs new file mode 100644 index 000000000..34923d235 --- /dev/null +++ b/src/core/wxc_common/src/telemetry/policy.rs @@ -0,0 +1,495 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Administrative (MDM / Group Policy) telemetry policy. +//! +//! See `docs/telemetry/telemetry-policy.md` for the admin-facing reference and +//! `docs/telemetry/telemetry-consent-design.md` for how this composes with +//! user consent. In short: +//! +//! - An administrator (via Intune, another MDM, or Group Policy) may **deny** +//! MXC telemetry machine-wide by setting +//! `HKLM\SOFTWARE\Policies\Mxc\AllowTelemetry` (`REG_DWORD`). +//! - The policy is a **ceiling, never a grant**. An administrator who permits +//! telemetry does not thereby consent on the user's behalf: MXC still +//! requires an explicit, persisted [`super::consent::ConsentState::Granted`] +//! from the user. There is no policy value that can turn telemetry on. +//! - MXC deliberately does **not** read the Windows-wide `AllowTelemetry` +//! setting. Microsoft's Policy CSP documentation scopes that policy to "the +//! operating system and apps that are considered part of Windows" and states +//! it "doesn't apply to any additional apps installed by your organization", +//! and the Windows Business Division privacy guidance requires that +//! app-classified components "build their own notice and consent experience +//! ... and should not rely on the Windows diagnostic consent". Reading the +//! OS setting would also mean reading Windows *consent* state, which MXC is +//! expressly forbidden from doing — the supported OS APIs for this +//! (`TelIsTelemetryTypeAllowed` and friends) fold the user's Settings-app +//! choice into their answer. +//! - Windows-only, like every other telemetry surface. On other platforms MXC +//! collects nothing at all, so there is nothing for a policy to restrict and +//! this module compiles down to a stub. +//! - Fails closed: an unreadable or unrecognized policy value denies. + +/// The administrator's telemetry decision for this machine. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PolicyState { + /// No administrative policy is configured. Telemetry is governed solely by + /// the user's own consent decision. This is *not* a grant. + Unrestricted, + /// An administrator has permitted optional (usage) telemetry, the category + /// MXC emits. Still requires user consent before anything is collected. + Allowed, + /// An administrator has denied MXC telemetry, or the configured value could + /// not be understood. Nothing is collected regardless of user consent, and + /// hosts must not offer a consent prompt. + Blocked, + /// Not a Windows host. MXC collects no telemetry here, so administrative + /// policy is not a meaningful concept. + NotApplicable, +} + +impl PolicyState { + /// Stable, lowercase wire representation used by the CLI flags, the FFI + /// boundary, and the SDKs. Kept separate from `Debug` so the on-the-wire + /// strings never drift if the Rust variant names change. + pub fn as_str(&self) -> &'static str { + match self { + PolicyState::Unrestricted => "unrestricted", + PolicyState::Allowed => "allowed", + PolicyState::Blocked => "blocked", + PolicyState::NotApplicable => "not-applicable", + } + } + + /// Whether telemetry collection is administratively permitted. + /// + /// True for every state except [`Blocked`](PolicyState::Blocked) — + /// including [`NotApplicable`](PolicyState::NotApplicable), because off + /// Windows the *consent* gate is what stops collection, and double-denying + /// here would wrongly imply an administrator had acted. + /// + /// This never means "collect": it is one conjunct of + /// [`super::is_enabled`], which also requires user consent. + pub fn allows_collection(&self) -> bool { + !matches!(self, PolicyState::Blocked) + } +} + +/// The `REG_DWORD` value an administrator sets to permit the optional (usage) +/// telemetry category that MXC emits, mirroring the Windows diagnostic-data +/// scale where `3` is Optional/Full. +#[cfg(target_os = "windows")] +const POLICY_VALUE_OPTIONAL: u32 = 3; + +/// Returns the current administrative telemetry policy. +/// +/// Fail-closed: a value that is present but not understood resolves to +/// [`PolicyState::Blocked`]. An *absent* policy resolves to +/// [`PolicyState::Unrestricted`] — the unmanaged default, where the user's own +/// consent decision governs. +pub fn get_policy() -> PolicyState { + platform::read() +} + +/// Whether an administrator has denied MXC telemetry on this machine. +/// +/// Convenience over [`get_policy`]; the inverse of +/// [`PolicyState::allows_collection`]. +pub fn is_blocked_by_policy() -> bool { + !get_policy().allows_collection() +} + +// --------------------------------------------------------------------------- +// Windows implementation +// --------------------------------------------------------------------------- + +#[cfg(target_os = "windows")] +mod platform { + use super::{PolicyState, POLICY_VALUE_OPTIONAL}; + use winreg::enums::HKEY_LOCAL_MACHINE; + use winreg::RegKey; + + /// Machine-wide policy key. Under `SOFTWARE\Policies`, so it is writable + /// only by administrators — a standard user cannot forge a permit here. + /// + /// Deliberately *not* under `Policies\Microsoft`, even though MXC is a + /// Microsoft product. Windows forbids ADMX-ingested policies from writing + /// under `System`, `Software\Microsoft`, or `Software\Policies\Microsoft` + /// except for a hardcoded allowlist (Office, Edge, OneDrive, VisualStudio, + /// …) that MXC is not on and cannot join without a Windows servicing + /// change. A key under those prefixes would make `Mxc.admx` un-ingestible + /// by Intune and every other MDM. `SOFTWARE\Policies\` is the shape + /// Microsoft's own ADMX-ingestion documentation uses for third-party apps. + /// + /// This is the administrator-facing contract documented in + /// `docs/telemetry/telemetry-policy.md`; changing it breaks deployed + /// policy. + const POLICY_SUBKEY: &str = r"SOFTWARE\Policies\Mxc"; + const POLICY_VALUE_NAME: &str = "AllowTelemetry"; + + /// The outcome of looking for the policy value. + /// + /// The distinction between [`Absent`](PolicyValue::Absent) and + /// [`Unreadable`](PolicyValue::Unreadable) is load-bearing: only a + /// genuinely missing policy means "unmanaged". Anything that exists but + /// cannot be understood must deny, or an administrator who misconfigured + /// the value would silently get collection instead of the block they + /// intended. + enum PolicyValue { + /// The key or the value genuinely does not exist — no policy is + /// configured, so the machine is unmanaged for MXC telemetry. + Absent, + /// A `REG_DWORD` was read successfully. + Value(u32), + /// A policy is configured but could not be read: wrong value type + /// (e.g. `REG_SZ`), access denied, or a corrupt/failing registry. + Unreadable, + } + + pub(super) fn read() -> PolicyState { + match read_policy_value() { + PolicyValue::Absent => PolicyState::Unrestricted, + PolicyValue::Value(POLICY_VALUE_OPTIONAL) => PolicyState::Allowed, + // Every other value — including `0` (off) and `1` (required-only, + // a category MXC does not emit) — denies. Unrecognized values deny + // too rather than being guessed at: fail closed. + PolicyValue::Value(_) => PolicyState::Blocked, + // Fail closed. An administrator who typed the value in as a string, + // or a machine whose registry we cannot read, must not be treated + // as unmanaged. + PolicyValue::Unreadable => PolicyState::Blocked, + } + } + + /// Reads the policy value, distinguishing "not configured" from + /// "configured but unreadable". + /// + /// `winreg` surfaces registry failures as [`std::io::Error`]; a missing key + /// or value is `ERROR_FILE_NOT_FOUND`, which maps to + /// [`std::io::ErrorKind::NotFound`]. Every other error — notably + /// `ErrorKind::InvalidData` for a non-`REG_DWORD` value, and + /// `PermissionDenied` for an ACL that hides the key — is a policy we cannot + /// evaluate, and therefore a deny. + fn read_policy_value() -> PolicyValue { + let (hive, subkey) = policy_location(); + let key = match RegKey::predef(hive).open_subkey(subkey) { + Ok(key) => key, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return PolicyValue::Absent, + Err(_) => return PolicyValue::Unreadable, + }; + match key.get_value::(POLICY_VALUE_NAME) { + Ok(value) => PolicyValue::Value(value), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => PolicyValue::Absent, + Err(_) => PolicyValue::Unreadable, + } + } + + /// Resolves the hive and subkey to read the policy from. + /// + /// Always the real machine policy location, except under test where + /// [`debug_policy_key_override`] can redirect it under `HKEY_CURRENT_USER` + /// so tests can exercise the real registry code path without requiring + /// administrator rights. The override is compiled out of every shipped + /// binary, so a release build can never be pointed at a user-writable key. + fn policy_location() -> (winreg::HKEY, String) { + #[cfg(any(test, debug_assertions))] + if let Some(subkey) = debug_policy_key_override() { + return (winreg::enums::HKEY_CURRENT_USER, subkey); + } + (HKEY_LOCAL_MACHINE, POLICY_SUBKEY.to_string()) + } + + /// Test-only hook: redirects the policy read to `HKCU\`. + /// + /// Active under `cfg(test)` (this crate's own harness — CI runs + /// `cargo test --release`, so a `debug_assertions`-only gate would drop + /// every policy test from CI and make them read real machine policy) or + /// in a debug build (for `mxc_ffi`'s cross-crate tests). Never present in + /// a binary MXC ships, which is neither. + #[cfg(any(test, debug_assertions))] + fn debug_policy_key_override() -> Option { + std::env::var("MXC_TEST_POLICY_KEY_OVERRIDE") + .ok() + .filter(|s| !s.is_empty()) + } + + /// The production key path is never exercised by the rest of the suite — + /// every test redirects to `HKCU` via `MXC_TEST_POLICY_KEY_OVERRIDE` — so + /// this guards the one property of it that silently breaks administrators. + #[cfg(test)] + mod key_path { + use super::POLICY_SUBKEY; + + /// Windows refuses to let an ADMX-ingested policy write under these + /// prefixes (outside a hardcoded allowlist MXC is not on). A key under + /// one of them cannot be deployed by Intune or any other MDM, so moving + /// MXC's key back under `Policies\Microsoft` — a natural-looking + /// "correction" for a Microsoft product — must fail loudly here rather + /// than at an administrator's next ADMX import. + #[test] + fn is_not_under_an_admx_ingestion_blocked_prefix() { + const BLOCKED_PREFIXES: [&str; 3] = [ + r"SYSTEM\", + r"SOFTWARE\MICROSOFT\", + r"SOFTWARE\POLICIES\MICROSOFT\", + ]; + + let upper = POLICY_SUBKEY.to_ascii_uppercase(); + for blocked in BLOCKED_PREFIXES { + assert!( + !upper.starts_with(blocked), + "policy key {POLICY_SUBKEY:?} sits under {blocked:?}, which Windows \ + forbids ADMX-ingested policies from writing; see \ + docs/telemetry/telemetry-policy.md" + ); + } + } + } +} + +// --------------------------------------------------------------------------- +// Non-Windows stub — nothing is collected here, so nothing is restricted. +// --------------------------------------------------------------------------- + +#[cfg(not(target_os = "windows"))] +mod platform { + use super::PolicyState; + + pub(super) fn read() -> PolicyState { + PolicyState::NotApplicable + } +} + +// --------------------------------------------------------------------------- +// Test support — the policy location is process-global state on Windows, so +// tests that redirect it must serialize against each other. +// --------------------------------------------------------------------------- + +#[cfg(any(test, feature = "test-support"))] +pub mod test_support { + use std::sync::{Mutex, MutexGuard}; + + /// Serializes access to the debug-only policy-key override, which is + /// process-global. Held for the whole lifetime of [`PolicyKeyGuard`] so a + /// caller cannot construct the guard without also taking the lock. + pub static POLICY_LOCK: Mutex<()> = Mutex::new(()); + + /// Redirects the policy read (Windows only) to a freshly created, unique + /// `HKCU` subkey for the lifetime of the guard, deleting it and restoring + /// the previous override on drop. + /// + /// Using a real registry key rather than a stubbed value means the tests + /// exercise the actual `winreg` read path. `HKCU` needs no elevation. + /// + /// An inert holder on non-Windows, where policy is `NotApplicable` + /// regardless — kept as a real guard type so call sites need no `#[cfg]`. + /// + /// **Within `wxc_common`, never construct this directly alongside the + /// consent guard** — use `crate::telemetry::test_support::TelemetryTestEnv`, + /// which fixes the acquisition order of the two process-global locks. + pub struct PolicyKeyGuard { + _lock: MutexGuard<'static, ()>, + #[cfg(target_os = "windows")] + subkey: String, + #[cfg(target_os = "windows")] + previous: Option, + } + + // `Default` is deliberately not implemented: constructing this guard takes a + // process-global lock and mutates the environment, which is not what a + // caller reaching for `Default::default()` expects. + #[allow(clippy::new_without_default)] + impl PolicyKeyGuard { + /// Creates the guard with no policy value set (the unmanaged default). + #[cfg(target_os = "windows")] + pub fn new() -> Self { + use std::sync::atomic::{AtomicU32, Ordering}; + static COUNTER: AtomicU32 = AtomicU32::new(0); + + let lock = POLICY_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let subkey = format!( + r"Software\MxcTelemetryPolicyTest\{}-{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::Relaxed) + ); + let previous = std::env::var_os("MXC_TEST_POLICY_KEY_OVERRIDE"); + + let hkcu = winreg::RegKey::predef(winreg::enums::HKEY_CURRENT_USER); + // Remove any leftover from a previously crashed run before use. + let _ = hkcu.delete_subkey_all(&subkey); + hkcu.create_subkey(&subkey).expect("create test policy key"); + std::env::set_var("MXC_TEST_POLICY_KEY_OVERRIDE", &subkey); + + Self { + _lock: lock, + subkey, + previous, + } + } + + #[cfg(not(target_os = "windows"))] + pub fn new() -> Self { + let lock = POLICY_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + Self { _lock: lock } + } + + /// Writes the `AllowTelemetry` policy value into the redirected key. + #[cfg(target_os = "windows")] + pub fn set_value(&self, value: u32) { + let (key, _) = winreg::RegKey::predef(winreg::enums::HKEY_CURRENT_USER) + .create_subkey(&self.subkey) + .expect("open test policy key"); + key.set_value("AllowTelemetry", &value) + .expect("set test policy value"); + } + + #[cfg(not(target_os = "windows"))] + pub fn set_value(&self, _value: u32) {} + + /// Writes `AllowTelemetry` as a `REG_SZ` instead of a `REG_DWORD`, to + /// exercise the wrong-value-type path an administrator can easily hit + /// by typing the value in by hand. + #[cfg(target_os = "windows")] + pub fn set_string_value(&self, value: &str) { + let (key, _) = winreg::RegKey::predef(winreg::enums::HKEY_CURRENT_USER) + .create_subkey(&self.subkey) + .expect("open test policy key"); + key.set_value("AllowTelemetry", &value.to_string()) + .expect("set test policy string value"); + } + + #[cfg(not(target_os = "windows"))] + pub fn set_string_value(&self, _value: &str) {} + } + + #[cfg(target_os = "windows")] + impl Drop for PolicyKeyGuard { + fn drop(&mut self) { + let _ = winreg::RegKey::predef(winreg::enums::HKEY_CURRENT_USER) + .delete_subkey_all(&self.subkey); + match &self.previous { + Some(v) => std::env::set_var("MXC_TEST_POLICY_KEY_OVERRIDE", v), + None => std::env::remove_var("MXC_TEST_POLICY_KEY_OVERRIDE"), + } + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wire_strings_are_stable() { + assert_eq!(PolicyState::Unrestricted.as_str(), "unrestricted"); + assert_eq!(PolicyState::Allowed.as_str(), "allowed"); + assert_eq!(PolicyState::Blocked.as_str(), "blocked"); + assert_eq!(PolicyState::NotApplicable.as_str(), "not-applicable"); + } + + #[test] + fn only_blocked_denies_collection() { + assert!(PolicyState::Unrestricted.allows_collection()); + assert!(PolicyState::Allowed.allows_collection()); + assert!(PolicyState::NotApplicable.allows_collection()); + assert!(!PolicyState::Blocked.allows_collection()); + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn policy_is_not_applicable_off_windows() { + assert_eq!(get_policy(), PolicyState::NotApplicable); + assert!(!is_blocked_by_policy()); + } +} + +#[cfg(all(test, target_os = "windows"))] +mod windows_tests { + use super::test_support::PolicyKeyGuard; + use super::*; + + #[test] + fn absent_policy_is_unrestricted() { + let _guard = PolicyKeyGuard::new(); + assert_eq!(get_policy(), PolicyState::Unrestricted); + assert!(!is_blocked_by_policy()); + } + + #[test] + fn optional_level_is_allowed() { + let guard = PolicyKeyGuard::new(); + guard.set_value(3); + assert_eq!(get_policy(), PolicyState::Allowed); + assert!(!is_blocked_by_policy()); + } + + #[test] + fn off_level_is_blocked() { + let guard = PolicyKeyGuard::new(); + guard.set_value(0); + assert_eq!(get_policy(), PolicyState::Blocked); + assert!(is_blocked_by_policy()); + } + + /// Level 1 is "required diagnostic data only". MXC emits + /// product-and-service-usage data, which is *optional*, so a + /// required-only machine must collect nothing. + #[test] + fn required_only_level_is_blocked() { + let guard = PolicyKeyGuard::new(); + guard.set_value(1); + assert_eq!(get_policy(), PolicyState::Blocked); + } + + /// Fail closed: a value outside the documented scale is a + /// misconfiguration, and MXC must not read it as permission. + #[test] + fn unrecognized_value_is_blocked() { + let guard = PolicyKeyGuard::new(); + for value in [2u32, 4, 99, u32::MAX] { + guard.set_value(value); + assert_eq!( + get_policy(), + PolicyState::Blocked, + "value {value} must fail closed" + ); + } + } + + /// An administrator who sets `AllowTelemetry` as a string rather than a + /// `REG_DWORD` has still expressed an intent to manage this machine. The + /// value cannot be evaluated, so it must deny — never be mistaken for an + /// unmanaged machine, which would let a prior consent grant re-enable + /// collection the administrator meant to stop. + #[test] + fn wrong_value_type_is_blocked_not_unrestricted() { + let guard = PolicyKeyGuard::new(); + for value in ["0", "3", "", "not-a-number"] { + guard.set_string_value(value); + assert_eq!( + get_policy(), + PolicyState::Blocked, + "REG_SZ {value:?} must fail closed, not read as unmanaged" + ); + assert!(is_blocked_by_policy()); + } + } + + /// The precise regression this guards: a wrong-typed value must not + /// resolve to the same state as no policy at all. + #[test] + fn wrong_value_type_is_distinguishable_from_absent() { + let guard = PolicyKeyGuard::new(); + assert_eq!(get_policy(), PolicyState::Unrestricted); + guard.set_string_value("0"); + assert_ne!( + get_policy(), + PolicyState::Unrestricted, + "a malformed policy must not be indistinguishable from an unmanaged machine" + ); + } +} diff --git a/src/core/wxc_common/src/wire.rs b/src/core/wxc_common/src/wire.rs index 7b7a39458..eb48fcca8 100644 --- a/src/core/wxc_common/src/wire.rs +++ b/src/core/wxc_common/src/wire.rs @@ -568,8 +568,10 @@ pub struct Experimental { #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase")] pub struct Telemetry { - /// Explicit telemetry override. `true` = force on, `false` = force off, - /// omitted = disabled (default off). + /// Explicit telemetry opt-in for this invocation. `true` = opt in (still + /// subject to the user's consent and to administrative policy — it can + /// never turn telemetry on for someone who has not consented), `false` = + /// force off, omitted = off. pub enabled: Option, } diff --git a/src/ffi/mxc_ffi/Cargo.toml b/src/ffi/mxc_ffi/Cargo.toml index 279af6b8b..f8b2dc5cb 100644 --- a/src/ffi/mxc_ffi/Cargo.toml +++ b/src/ffi/mxc_ffi/Cargo.toml @@ -14,6 +14,7 @@ crate-type = ["cdylib", "staticlib", "lib"] [dependencies] mxc-sdk = { workspace = true } +wxc_common = { workspace = true } serde_json = { workspace = true } [features] @@ -24,5 +25,11 @@ serde_json = { workspace = true } # regenerate the committed bindings. dotnetsdk = ["dep:csbindgen"] +[dev-dependencies] +# Gives the FFI tests the policy-key redirector, so they can assert the exact +# string this layer marshals for each administrative policy state rather than +# accepting whatever the host machine's real policy happens to produce. +wxc_common = { workspace = true, features = ["test-support"] } + [build-dependencies] csbindgen = { version = "1", optional = true } diff --git a/src/ffi/mxc_ffi/src/lib.rs b/src/ffi/mxc_ffi/src/lib.rs index 6b4aadb49..f2d69c9a0 100644 --- a/src/ffi/mxc_ffi/src/lib.rs +++ b/src/ffi/mxc_ffi/src/lib.rs @@ -44,7 +44,9 @@ //! frozen ABI to link third-party consumers against; consume MXC through a //! versioned binding (the C# SDK) matched to the same release. +use std::any::Any; use std::ffi::{c_char, CStr, CString}; +use std::io::{self, Write}; use std::panic::catch_unwind; use std::ptr; use std::sync::OnceLock; @@ -56,6 +58,48 @@ mod streaming; pub use state_aware::*; pub use streaming::*; +/// Write a diagnostic line to stderr without ever panicking. +/// +/// `eprintln!` **panics** if the write fails, and a closed or broken stderr is +/// routine when a host redirects its streams. Every caller here runs either at +/// an `extern "C"` boundary or while unwinding from one, where a panic would +/// abort the process or unwind into foreign frames (undefined behaviour). So +/// the write has to be infallible by construction, not merely unlikely to fail. +fn report_to_stderr(args: std::fmt::Arguments<'_>) { + let stderr = io::stderr(); + let mut handle = stderr.lock(); + let _ = handle.write_fmt(args); + let _ = handle.write_all(b"\n"); + let _ = handle.flush(); +} + +/// Report a panic caught at the FFI boundary. +/// +/// A panic here is always a bug in MXC, and `catch_unwind` would otherwise +/// discard the payload entirely — leaving the host with a bare status code and +/// no way to find out what failed. Writing to stderr keeps the diagnosis +/// possible without unwinding into the host's foreign frames, which would be +/// undefined behaviour. +/// +/// Deliberately unconditional (not gated behind `MXC_DIAG_CONSOLE`): this path +/// is a should-never-happen bug, not routine diagnostic chatter, and it cannot +/// spam because the operation has already failed. +/// +/// Never panics itself: the write goes through [`report_to_stderr`], which +/// discards I/O errors, and the payload downcast falls back to a placeholder. +/// A second panic while already unwinding would abort the embedding process. +fn report_panic(operation: &str, payload: &(dyn Any + Send)) { + let message = payload + .downcast_ref::<&str>() + .copied() + .or_else(|| payload.downcast_ref::().map(String::as_str)) + .unwrap_or(""); + report_to_stderr(format_args!( + "mxc: internal error: panic caught at the FFI boundary in {operation}: {message}. \ + Failing closed; the calling process is unaffected." + )); +} + // --------------------------------------------------------------------------- // Status codes // --------------------------------------------------------------------------- @@ -95,6 +139,10 @@ pub const MXC_STATUS_NULL_ARGUMENT: i32 = 100; pub const MXC_STATUS_INVALID_UTF8: i32 = 101; /// The Rust side panicked; the panic was caught at the boundary. pub const MXC_STATUS_PANIC: i32 = 102; +/// Telemetry consent could not be persisted (e.g. non-Windows host, or +/// `%LOCALAPPDATA%` unavailable/unwritable). Never returned by +/// [`mxc_telemetry_get_consent`], which always succeeds. +pub const MXC_STATUS_CONSENT_WRITE_FAILED: i32 = 103; /// Map an [`ErrorCode`] to its stable FFI status code. pub(crate) fn status_from_error_code(code: ErrorCode) -> i32 { @@ -234,8 +282,10 @@ pub unsafe extern "C" fn mxc_run( command_utf8: *const c_char, out: *mut MxcRunResult, ) -> i32 { - let result = catch_unwind(|| run_inner(policy_json_utf8, command_utf8)) - .unwrap_or_else(|_| MxcRunResult::error(MXC_STATUS_PANIC, "the mxc engine panicked")); + let result = catch_unwind(|| run_inner(policy_json_utf8, command_utf8)).unwrap_or_else(|p| { + report_panic("mxc_run", &*p); + MxcRunResult::error(MXC_STATUS_PANIC, "the mxc engine panicked") + }); if out.is_null() { // Nowhere to hand ownership; free anything we allocated to avoid a leak. @@ -319,10 +369,12 @@ pub unsafe extern "C" fn mxc_run_result_free(r: *mut MxcRunResult) { if r.is_null() { return; } - let _ = catch_unwind(|| { + if let Err(p) = catch_unwind(|| { // SAFETY: caller guarantees `r` points to a valid, not-yet-freed result. unsafe { (*r).free_strings() }; - }); + }) { + report_panic("mxc_run_result_free", &*p); + } } /// Free a single heap C string returned by this library. @@ -335,10 +387,12 @@ pub unsafe extern "C" fn mxc_string_free(s: *mut c_char) { if s.is_null() { return; } - let _ = catch_unwind(|| { + if let Err(p) = catch_unwind(|| { let mut p = s; free_cstr(&mut p); - }); + }) { + report_panic("mxc_string_free", &*p); + } } /// Return the library version as a static, NUL-terminated C string. @@ -353,6 +407,175 @@ pub extern "C" fn mxc_version() -> *const c_char { .as_ptr() } +// --------------------------------------------------------------------------- +// Telemetry consent +// --------------------------------------------------------------------------- +// +// See docs/telemetry/telemetry-consent-design.md. MXC only ever collects +// telemetry on Windows, and only when this persisted, MXC-owned consent flag +// is granted — never derived from any Windows-level diagnostics setting. +// `wxc_common::telemetry::consent` compiles a non-Windows stub that always +// reports "not-applicable" and rejects writes, so these entry points behave +// identically here across platforms: callers get one C ABI regardless of +// host OS, and the platform gate lives in exactly one place (the Rust +// module), not duplicated at the FFI boundary. + +/// Read the persisted telemetry consent state. +/// +/// Always succeeds and writes one of `"granted"`, `"denied"`, +/// `"undetermined"`, or `"not-applicable"` (non-Windows hosts) into +/// `*out_utf8` as a heap-allocated, NUL-terminated UTF-8 string. The caller +/// must free it with [`mxc_string_free`]. +/// +/// Returns [`MXC_STATUS_SUCCESS`] on success, or [`MXC_STATUS_NULL_ARGUMENT`] +/// without touching `*out_utf8` if `out_utf8` is null. +/// +/// # Safety +/// `out_utf8` must be null or point to writable `*mut c_char`-sized storage. +#[no_mangle] +pub unsafe extern "C" fn mxc_telemetry_get_consent(out_utf8: *mut *mut c_char) -> i32 { + let result = catch_unwind(|| wxc_common::telemetry::consent::get_consent().as_str()); + let state_str = match result { + Ok(s) => s, + Err(p) => { + report_panic("mxc_telemetry_get_consent", &*p); + return MXC_STATUS_PANIC; + } + }; + + if out_utf8.is_null() { + return MXC_STATUS_NULL_ARGUMENT; + } + // SAFETY: `out_utf8` is non-null and caller-guaranteed writable. + unsafe { ptr::write(out_utf8, alloc_cstring(state_str.as_bytes())) }; + MXC_STATUS_SUCCESS +} + +/// Grant or revoke telemetry consent and persist the decision. +/// +/// `granted` is `1` to grant, `0` to revoke/deny. `source_utf8` is an +/// optional, free-form provenance string (e.g. `"prompt"`, `"settings-ui"`) +/// recorded for support/debugging only — it is never transmitted anywhere. A +/// null `source_utf8` records `"sdk"`. +/// +/// Returns [`MXC_STATUS_SUCCESS`] on success. Returns +/// [`MXC_STATUS_CONSENT_WRITE_FAILED`] if the decision could not be +/// persisted — always the case on non-Windows hosts, since MXC must not +/// collect (and therefore must not offer consent for) telemetry there. +/// +/// # Safety +/// `source_utf8` must be null or a valid NUL-terminated UTF-8 C string. +#[no_mangle] +pub unsafe extern "C" fn mxc_telemetry_set_consent( + granted: i32, + source_utf8: *const c_char, +) -> i32 { + // SAFETY: caller contract above. + let source = match unsafe { cstr_to_str(source_utf8) } { + Some(s) => s, + None if source_utf8.is_null() => "sdk", + None => return MXC_STATUS_INVALID_UTF8, + }; + let source = source.to_string(); + let granted = granted != 0; + + let result = catch_unwind(|| wxc_common::telemetry::consent::set_consent(granted, &source)); + match result { + Ok(Ok(())) => MXC_STATUS_SUCCESS, + Ok(Err(e)) => { + // The status code alone cannot say *why* the write failed (missing + // profile directory, denied ACL, read-only volume). Without this the + // reason is lost and a host sees only "consent did not stick". + // This arm runs *outside* `catch_unwind`, so it must not panic. + report_to_stderr(format_args!( + "mxc: failed to persist telemetry consent: {e}" + )); + MXC_STATUS_CONSENT_WRITE_FAILED + } + Err(p) => { + report_panic("mxc_telemetry_set_consent", &*p); + MXC_STATUS_PANIC + } + } +} + +/// Whether a hosting application should offer its own first-run telemetry +/// consent prompt. +/// +/// Writes `1` or `0` into `*out_needs_prompt`. Always `0` on non-Windows +/// hosts, where MXC collects no telemetry and consent is not a meaningful +/// concept. +/// +/// This is exported rather than left for each binding to derive from +/// [`mxc_telemetry_get_consent`] so that the prompt policy has exactly one +/// implementation (`ConsentState::needs_prompt`) shared by every language. +/// +/// Returns [`MXC_STATUS_SUCCESS`] on success, or [`MXC_STATUS_NULL_ARGUMENT`] +/// without touching `*out_needs_prompt` if it is null. +/// +/// # Safety +/// `out_needs_prompt` must be null or point to writable `i32`-sized storage. +#[no_mangle] +pub unsafe extern "C" fn mxc_telemetry_needs_consent_prompt(out_needs_prompt: *mut i32) -> i32 { + if out_needs_prompt.is_null() { + return MXC_STATUS_NULL_ARGUMENT; + } + + let needs_prompt = match catch_unwind(wxc_common::telemetry::consent::needs_consent_prompt) { + Ok(b) => b, + Err(p) => { + report_panic("mxc_telemetry_needs_consent_prompt", &*p); + return MXC_STATUS_PANIC; + } + }; + + // SAFETY: `out_needs_prompt` is non-null and caller-guaranteed writable. + unsafe { ptr::write(out_needs_prompt, i32::from(needs_prompt)) }; + MXC_STATUS_SUCCESS +} + +/// Read the administrative (MDM / Group Policy) telemetry policy. +/// +/// Always succeeds and writes one of `"unrestricted"` (no policy configured), +/// `"allowed"`, `"blocked"`, or `"not-applicable"` (non-Windows hosts) into +/// `*out_utf8` as a heap-allocated, NUL-terminated UTF-8 string. The caller +/// must free it with [`mxc_string_free`]. +/// +/// The policy is a *ceiling*, never a grant: `"allowed"` does not mean +/// telemetry is on, only that an administrator has not forbidden it. An +/// explicit user consent grant is still required. `"blocked"` means nothing is +/// collected regardless of consent, and a host must not offer a consent +/// prompt — [`mxc_telemetry_needs_consent_prompt`] already reports `0` in that +/// case. +/// +/// Exposed so a host can distinguish "the user has not opted in" from "an +/// administrator has disabled this" and explain the difference, rather than +/// rendering a toggle that silently does nothing. +/// +/// Returns [`MXC_STATUS_SUCCESS`] on success, or [`MXC_STATUS_NULL_ARGUMENT`] +/// without touching `*out_utf8` if `out_utf8` is null. +/// +/// # Safety +/// `out_utf8` must be null or point to writable `*mut c_char`-sized storage. +#[no_mangle] +pub unsafe extern "C" fn mxc_telemetry_get_policy(out_utf8: *mut *mut c_char) -> i32 { + let result = catch_unwind(|| wxc_common::telemetry::policy::get_policy().as_str()); + let state_str = match result { + Ok(s) => s, + Err(p) => { + report_panic("mxc_telemetry_get_policy", &*p); + return MXC_STATUS_PANIC; + } + }; + + if out_utf8.is_null() { + return MXC_STATUS_NULL_ARGUMENT; + } + // SAFETY: `out_utf8` is non-null and caller-guaranteed writable. + unsafe { ptr::write(out_utf8, alloc_cstring(state_str.as_bytes())) }; + MXC_STATUS_SUCCESS +} + #[cfg(test)] mod tests { use super::*; @@ -413,4 +636,295 @@ mod tests { mxc_string_free(ptr::null_mut()); } } + + // ----------------------------------------------------------------- + // Telemetry consent + // ----------------------------------------------------------------- + // + // These tests drive both process-global telemetry test overrides: + // `MXC_TEST_LOCALAPPDATA_OVERRIDE` (consent store) and the policy + // registry redirect. Both are debug-build-only escape hatches that + // `wxc_common::telemetry` reads instead of trusting the real + // `LOCALAPPDATA` / `HKLM` locations (see those modules for the security + // rationale — a release binary compiles both overrides out entirely). + // + // Because the overrides do not exist in a release build, the whole + // section below is `#[cfg(debug_assertions)]`. Without that gate, + // `cargo test -p mxc_ffi --release` would silently read and *write* the + // developer's real telemetry consent record: the tests would still pass, + // having proved nothing and mutated live state. + // + // Everything here serializes on `CONSENT_ENV_LOCK`, and `TelemetryTestEnv` + // additionally takes `POLICY_LOCK` via its `PolicyKeyGuard`, so consent + // tests are mutually exclusive both with each other and with the policy + // tests further down. The `mxc_run` tests above touch neither override and + // may run in parallel with these. + #[cfg(debug_assertions)] + static CONSENT_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + /// Isolates *both* process-global telemetry test overrides for the + /// lifetime of the guard: `MXC_TEST_LOCALAPPDATA_OVERRIDE` points at a + /// fresh temp directory, and the administrative policy read is redirected + /// to a fresh, empty `HKCU` key. + /// + /// Both are required even for a consent-only assertion. `needs_prompt` is + /// `consent AND policy`, so a policy test mutating the shared registry + /// override concurrently would flip a consent test's expected result; + /// equally, an ambient policy genuinely configured on the developer's + /// machine would otherwise leak into these assertions. Owning the + /// [`PolicyKeyGuard`] here takes `POLICY_LOCK` too, which mutually + /// excludes the policy tests below. + /// + /// The two locks are always taken in this order — consent, then policy — + /// and this is the only place in the crate that holds both, so the + /// ordering cannot deadlock. + #[cfg(debug_assertions)] + struct TelemetryTestEnv { + _lock: std::sync::MutexGuard<'static, ()>, + _policy: wxc_common::telemetry::policy::test_support::PolicyKeyGuard, + original: Option, + _dir: tempfile_like::TempDir, + } + + // A tiny, dependency-free stand-in for a temp directory: create a unique + // subdirectory under `env::temp_dir()` and remove it on drop. Avoids + // pulling in the `tempfile` crate for two tests. + #[cfg(debug_assertions)] + mod tempfile_like { + use std::path::{Path, PathBuf}; + + pub struct TempDir(PathBuf); + + impl TempDir { + pub fn new(label: &str) -> Self { + let mut path = std::env::temp_dir(); + path.push(format!( + "mxc_ffi_consent_test_{label}_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&path).expect("create temp dir"); + Self(path) + } + + pub fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + } + + #[cfg(debug_assertions)] + impl TelemetryTestEnv { + fn new(label: &str) -> Self { + let lock = CONSENT_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let policy = wxc_common::telemetry::policy::test_support::PolicyKeyGuard::new(); + let original = std::env::var_os("MXC_TEST_LOCALAPPDATA_OVERRIDE"); + let dir = tempfile_like::TempDir::new(label); + std::env::set_var("MXC_TEST_LOCALAPPDATA_OVERRIDE", dir.path()); + Self { + _lock: lock, + _policy: policy, + original, + _dir: dir, + } + } + } + + #[cfg(debug_assertions)] + impl Drop for TelemetryTestEnv { + fn drop(&mut self) { + match &self.original { + Some(v) => std::env::set_var("MXC_TEST_LOCALAPPDATA_OVERRIDE", v), + None => std::env::remove_var("MXC_TEST_LOCALAPPDATA_OVERRIDE"), + } + } + } + + #[cfg(debug_assertions)] + #[test] + fn get_consent_reports_string_and_never_errors() { + let _guard = TelemetryTestEnv::new("get_default"); + let mut out: *mut c_char = ptr::null_mut(); + // SAFETY: `out` is a valid writable pointer to a local variable. + let status = unsafe { mxc_telemetry_get_consent(&mut out) }; + assert_eq!(status, MXC_STATUS_SUCCESS); + assert!(!out.is_null()); + // SAFETY: `out` was just allocated by `mxc_telemetry_get_consent`. + let s = unsafe { CStr::from_ptr(out) }.to_str().unwrap().to_string(); + #[cfg(target_os = "windows")] + assert_eq!(s, "undetermined"); + #[cfg(not(target_os = "windows"))] + assert_eq!(s, "not-applicable"); + // SAFETY: `out` was allocated via `alloc_cstring`/`CString::into_raw`. + unsafe { mxc_string_free(out) }; + } + + #[cfg(debug_assertions)] + #[test] + fn get_consent_null_out_reports_null_argument() { + let _guard = TelemetryTestEnv::new("get_null_out"); + // SAFETY: null out-pointer is explicitly handled. + let status = unsafe { mxc_telemetry_get_consent(ptr::null_mut()) }; + assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); + } + + #[cfg(debug_assertions)] + #[test] + fn set_then_get_consent_round_trips() { + let _guard = TelemetryTestEnv::new("round_trip"); + let source = CString::new("prompt").unwrap(); + // SAFETY: `source` is a valid NUL-terminated UTF-8 C string. + let set_status = unsafe { mxc_telemetry_set_consent(1, source.as_ptr()) }; + + let mut out: *mut c_char = ptr::null_mut(); + // SAFETY: `out` is a valid writable pointer to a local variable. + let get_status = unsafe { mxc_telemetry_get_consent(&mut out) }; + assert_eq!(get_status, MXC_STATUS_SUCCESS); + // SAFETY: `out` was just allocated by `mxc_telemetry_get_consent`. + let s = unsafe { CStr::from_ptr(out) }.to_str().unwrap().to_string(); + unsafe { mxc_string_free(out) }; + + #[cfg(target_os = "windows")] + { + assert_eq!(set_status, MXC_STATUS_SUCCESS); + assert_eq!(s, "granted"); + } + #[cfg(not(target_os = "windows"))] + { + assert_eq!(set_status, MXC_STATUS_CONSENT_WRITE_FAILED); + assert_eq!(s, "not-applicable"); + } + } + + #[cfg(debug_assertions)] + #[test] + fn set_consent_null_source_defaults_to_sdk() { + let _guard = TelemetryTestEnv::new("null_source"); + // SAFETY: null source is explicitly allowed (defaults to "sdk"). + let status = unsafe { mxc_telemetry_set_consent(0, ptr::null()) }; + #[cfg(target_os = "windows")] + assert_eq!(status, MXC_STATUS_SUCCESS); + #[cfg(not(target_os = "windows"))] + assert_eq!(status, MXC_STATUS_CONSENT_WRITE_FAILED); + } + + #[cfg(debug_assertions)] + #[test] + fn needs_consent_prompt_tracks_the_store() { + let _guard = TelemetryTestEnv::new("needs_prompt"); + let mut needs: i32 = -1; + // SAFETY: `needs` is a valid writable pointer to a local variable. + let status = unsafe { mxc_telemetry_needs_consent_prompt(&mut needs) }; + assert_eq!(status, MXC_STATUS_SUCCESS); + // Fresh store on Windows must prompt; off Windows nothing is collected + // so nothing may be asked. + #[cfg(target_os = "windows")] + assert_eq!(needs, 1); + #[cfg(not(target_os = "windows"))] + assert_eq!(needs, 0); + + #[cfg(target_os = "windows")] + { + let source = CString::new("prompt").unwrap(); + // SAFETY: `source` is a valid NUL-terminated UTF-8 C string. + assert_eq!( + unsafe { mxc_telemetry_set_consent(0, source.as_ptr()) }, + MXC_STATUS_SUCCESS + ); + // SAFETY: `needs` is a valid writable pointer to a local variable. + let status = unsafe { mxc_telemetry_needs_consent_prompt(&mut needs) }; + assert_eq!(status, MXC_STATUS_SUCCESS); + assert_eq!(needs, 0, "a recorded denial must not re-prompt"); + } + } + + #[cfg(debug_assertions)] + #[test] + fn needs_consent_prompt_null_out_reports_null_argument() { + let _guard = TelemetryTestEnv::new("needs_prompt_null"); + // SAFETY: null out-pointer is explicitly handled. + let status = unsafe { mxc_telemetry_needs_consent_prompt(ptr::null_mut()) }; + assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); + } + + /// The policy *semantics* (which registry values map to which state) are + /// exhaustively tested in `wxc_common::telemetry::policy`. What this layer + /// owns is that the export marshals the exact corresponding string, and + /// that the caller can free it. + #[test] + fn get_policy_returns_a_valid_state_string() { + let s = read_policy_string(); + #[cfg(target_os = "windows")] + assert!( + ["unrestricted", "allowed", "blocked"].contains(&s.as_str()), + "unexpected policy state {s:?}" + ); + #[cfg(not(target_os = "windows"))] + assert_eq!(s, "not-applicable"); + } + + /// Calls the export and returns the marshalled string, freeing the + /// allocation. Shared by the policy tests below. + fn read_policy_string() -> String { + let mut out: *mut c_char = ptr::null_mut(); + // SAFETY: `out` is a valid writable pointer to a local variable. + let status = unsafe { mxc_telemetry_get_policy(&mut out) }; + assert_eq!(status, MXC_STATUS_SUCCESS); + assert!(!out.is_null()); + // SAFETY: `out` was just allocated by `mxc_telemetry_get_policy`. + let s = unsafe { CStr::from_ptr(out) }.to_str().unwrap().to_string(); + unsafe { mxc_string_free(out) }; + s + } + + /// Drives every administrative policy state through a redirected registry + /// key and asserts the exact string this layer marshals for each. Without + /// this the export could return one hard-coded valid state and still pass. + /// + /// Debug-only: `PolicyKeyGuard` redirects the policy read, and that + /// override is compiled out of a release build by design. Without this + /// gate the test would read the developer's *real* machine policy and + /// fail (or, on an unmanaged machine, pass for the wrong reason). + #[cfg(all(target_os = "windows", debug_assertions))] + #[test] + fn get_policy_marshals_the_exact_state_for_each_registry_value() { + use wxc_common::telemetry::policy::test_support::PolicyKeyGuard; + + let guard = PolicyKeyGuard::new(); + // No value set: an unmanaged machine. + assert_eq!(read_policy_string(), "unrestricted"); + + guard.set_value(3); + assert_eq!(read_policy_string(), "allowed"); + + for blocked in [0u32, 1, 2, 99, u32::MAX] { + guard.set_value(blocked); + assert_eq!( + read_policy_string(), + "blocked", + "value {blocked} must marshal as blocked" + ); + } + + // A wrong-typed value is a policy we cannot evaluate: it must fail + // closed all the way out through the ABI, not read as unmanaged. + guard.set_string_value("0"); + assert_eq!(read_policy_string(), "blocked"); + } + + #[test] + fn get_policy_null_out_reports_null_argument() { + // SAFETY: null out-pointer is explicitly handled. + let status = unsafe { mxc_telemetry_get_policy(ptr::null_mut()) }; + assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); + } } diff --git a/tests/scripts/run_telemetry_consent_smoke_test.ps1 b/tests/scripts/run_telemetry_consent_smoke_test.ps1 new file mode 100644 index 000000000..724d22e9e --- /dev/null +++ b/tests/scripts/run_telemetry_consent_smoke_test.ps1 @@ -0,0 +1,202 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Telemetry consent CLI smoke test. +# +# Exercises `wxc-exec.exe --telemetry-consent-{status,grant,revoke,source}` +# end to end against an isolated consent store, so it never touches the real +# developer/CI-machine one. See docs/telemetry/telemetry-consent-design.md. +# +# Debug builds only, by design: isolation relies on +# MXC_TEST_LOCALAPPDATA_OVERRIDE, which wxc_common::telemetry::consent +# compiles out of release builds (a release binary always resolves the real +# per-user known-folder path). There is no safe way to run this against a +# release binary without mutating the real store, so -Release is refused +# rather than silently doing that. +# +# Usage: +# .\run_telemetry_consent_smoke_test.ps1 +# .\run_telemetry_consent_smoke_test.ps1 -BinDir + +param( + [string]$BinDir +) + +$ErrorActionPreference = "Stop" +$RepoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) + +if (-not $BinDir) { + $BinDir = Join-Path $RepoRoot "src\target\debug" +} + +$WxcExec = Join-Path $BinDir "wxc-exec.exe" + +if (-not (Test-Path $WxcExec)) { + Write-Host "ERROR: wxc-exec.exe not found at $WxcExec" -ForegroundColor Red + Write-Host "Run 'cargo build -p wxc' first (debug; see the note above about release builds)." -ForegroundColor Yellow + exit 1 +} + +function Assert-Consent { + param([string]$Actual, [string]$Expected, [string]$NeedsPrompt, [string]$Step, [string]$Policy = "unrestricted") + + try { + $parsed = $Actual | ConvertFrom-Json + } catch { + Write-Host "FAILED: $Step emitted output that is not valid JSON: '$Actual'" -ForegroundColor Red + exit 1 + } + + $expectedPrompt = [bool]::Parse($NeedsPrompt) + $failures = @() + if ($parsed.consent -ne $Expected) { $failures += "consent: expected '$Expected', got '$($parsed.consent)'" } + if ($parsed.needsPrompt -ne $expectedPrompt) { $failures += "needsPrompt: expected '$expectedPrompt', got '$($parsed.needsPrompt)'" } + if ($parsed.policy -ne $Policy) { $failures += "policy: expected '$Policy', got '$($parsed.policy)'" } + + if ($failures.Count -gt 0) { + Write-Host "FAILED: $Step" -ForegroundColor Red + foreach ($failure in $failures) { Write-Host " $failure" -ForegroundColor Red } + Write-Host " raw output: '$Actual'" -ForegroundColor Red + exit 1 + } + + Write-Host " OK: $Step -> $Actual" -ForegroundColor DarkGray +} + +# Redirect the consent store to an isolated temp directory for the duration +# of this script so it never reads or writes the real per-user consent file. +# This is the same debug-only override the Rust and C# tests use; production +# code path resolves the known folder directly and ignores LOCALAPPDATA. +$OverrideEnvVar = "MXC_TEST_LOCALAPPDATA_OVERRIDE" +$OriginalOverride = [Environment]::GetEnvironmentVariable($OverrideEnvVar) +$TempDir = Join-Path ([System.IO.Path]::GetTempPath()) "mxc_telemetry_consent_smoke_$([guid]::NewGuid().ToString('N'))" +New-Item -ItemType Directory -Path $TempDir | Out-Null +Set-Item -Path "Env:$OverrideEnvVar" -Value $TempDir + +# Likewise redirect the administrative policy read to a throwaway HKCU key, so +# the assertions below hold on a machine that genuinely has the MXC telemetry +# policy configured, and so this script can exercise the policy path without +# needing elevation. Also debug-build-only. +$PolicyEnvVar = "MXC_TEST_POLICY_KEY_OVERRIDE" +$OriginalPolicyOverride = [Environment]::GetEnvironmentVariable($PolicyEnvVar) +$PolicySubkey = "Software\MxcTelemetryPolicySmoke\$([guid]::NewGuid().ToString('N'))" +$PolicyPath = "HKCU:\$PolicySubkey" +New-Item -Path $PolicyPath -Force | Out-Null +Set-Item -Path "Env:$PolicyEnvVar" -Value $PolicySubkey + +$ConsentFile = Join-Path $TempDir "mxc\telemetry-consent.json" + +try { + Write-Host "Running telemetry consent CLI smoke test..." -ForegroundColor Cyan + + # Prove the store override is actually honored *before* issuing any command + # that writes. A release build compiles $OverrideEnvVar out, and every write + # below would then land in the developer's real per-user consent store and + # silently overwrite their genuine decision. + # + # The proof is read-only: seed two different records directly and require + # the CLI to report each one back. A binary ignoring the override reads a + # single fixed store, so it cannot match both. + New-Item -ItemType Directory -Path (Split-Path -Parent $ConsentFile) -Force | Out-Null + foreach ($seeded in @("granted", "denied")) { + $record = [pscustomobject]@{ + schemaVersion = 1 + consent = $seeded + source = "smoke-test-seed" + promptedMxcVersion = "0.0.0-smoke" + updatedAtEpoch = 0 + } + # Must be BOM-free: Windows PowerShell's `-Encoding UTF8` emits a BOM, + # which the JSON parser rejects, making a correctly isolated store look + # unreadable. + [System.IO.File]::WriteAllText( + $ConsentFile, + ($record | ConvertTo-Json), + (New-Object System.Text.UTF8Encoding $false)) + + $probe = & $WxcExec --telemetry-consent-status + if (($probe | ConvertFrom-Json).consent -ne $seeded) { + Write-Host "FAILED: consent store is NOT isolated - seeded '$seeded' at $ConsentFile" -ForegroundColor Red + Write-Host " but the CLI reported '$(($probe | ConvertFrom-Json).consent)'." -ForegroundColor Red + Write-Host " The wxc-exec.exe under test is most likely a release build, which compiles out" -ForegroundColor Yellow + Write-Host " $OverrideEnvVar. Rebuild with 'cargo build -p wxc' (debug) and re-run." -ForegroundColor Yellow + Write-Host " Refusing to continue: the remaining steps would write to your real consent store." -ForegroundColor Yellow + exit 1 + } + } + Remove-Item -Path $ConsentFile -Force + Write-Host " OK: consent store is isolated at $ConsentFile" -ForegroundColor DarkGray + + $status0 = & $WxcExec --telemetry-consent-status + Assert-Consent $status0 "undetermined" "true" "fresh store status" + + $grant = & $WxcExec --telemetry-consent-grant --telemetry-consent-source prompt + Assert-Consent $grant "granted" "false" "grant" + + if (-not (Test-Path $ConsentFile)) { + Write-Host "FAILED: grant did not persist a record to $ConsentFile." -ForegroundColor Red + exit 1 + } + Write-Host " OK: consent file persisted under the isolated store at $ConsentFile" -ForegroundColor DarkGray + + $status1 = & $WxcExec --telemetry-consent-status + Assert-Consent $status1 "granted" "false" "status after grant" + + $revoke = & $WxcExec --telemetry-consent-revoke --telemetry-consent-source settings-toggle + Assert-Consent $revoke "denied" "false" "revoke" + + $status2 = & $WxcExec --telemetry-consent-status + Assert-Consent $status2 "denied" "false" "status after revoke" + + & $WxcExec --telemetry-consent-grant --telemetry-consent-revoke | Out-Null + if ($LASTEXITCODE -eq 0) { + Write-Host "FAILED: --telemetry-consent-grant + --telemetry-consent-revoke should be rejected as mutually exclusive" -ForegroundColor Red + exit 1 + } + Write-Host " OK: grant+revoke rejected as mutually exclusive (exit $LASTEXITCODE)" -ForegroundColor DarkGray + + # Administrative (MDM / Group Policy) ceiling. Only the value 3 (Optional) + # permits the product-and-service-usage data MXC emits; everything else, + # including an unrecognised value, must fail closed. + & $WxcExec --telemetry-consent-grant --telemetry-consent-source cli | Out-Null + + foreach ($blocking in @(0, 1, 42)) { + Set-ItemProperty -Path $PolicyPath -Name "AllowTelemetry" -Value $blocking -Type DWord + $blocked = & $WxcExec --telemetry-consent-status + # The user's own grant is preserved verbatim; only the ceiling changes, + # and the prompt is suppressed so no host asks a moot question. + Assert-Consent $blocked "granted" "false" "policy AllowTelemetry=$blocking blocks" "blocked" + } + + Set-ItemProperty -Path $PolicyPath -Name "AllowTelemetry" -Value 3 -Type DWord + $allowed = & $WxcExec --telemetry-consent-status + Assert-Consent $allowed "granted" "false" "policy AllowTelemetry=3 allows" "allowed" + + # A value of the wrong registry type is unreadable, not absent. It must fail + # closed rather than degrade to "no policy configured" and re-enable + # collection an administrator was trying to turn off. + Remove-ItemProperty -Path $PolicyPath -Name "AllowTelemetry" + Set-ItemProperty -Path $PolicyPath -Name "AllowTelemetry" -Value "3" -Type String + $wrongType = & $WxcExec --telemetry-consent-status + Assert-Consent $wrongType "granted" "false" "REG_SZ AllowTelemetry blocks" "blocked" + + Remove-ItemProperty -Path $PolicyPath -Name "AllowTelemetry" + $unmanaged = & $WxcExec --telemetry-consent-status + Assert-Consent $unmanaged "granted" "false" "policy removed is unrestricted" + + # A blocking policy must also suppress the first-run prompt for a user who + # has *not* decided yet, not just for one who already has. + & $WxcExec --telemetry-consent-status | Out-Null + Remove-Item -Path $ConsentFile -Force + Set-ItemProperty -Path $PolicyPath -Name "AllowTelemetry" -Value 0 -Type DWord + $blockedFresh = & $WxcExec --telemetry-consent-status + Assert-Consent $blockedFresh "undetermined" "false" "blocked policy suppresses the first-run prompt" "blocked" + + Write-Host "PASSED: telemetry consent CLI smoke test" -ForegroundColor Green +} finally { + Set-Item -Path "Env:$OverrideEnvVar" -Value $OriginalOverride + Set-Item -Path "Env:$PolicyEnvVar" -Value $OriginalPolicyOverride + Remove-Item -Recurse -Force $TempDir -ErrorAction SilentlyContinue + Remove-Item -Recurse -Force $PolicyPath -ErrorAction SilentlyContinue +} + From fc6a8fffd7b88dfff73a021635cd339efff9edc0 Mon Sep 17 00:00:00 2001 From: RamonArjona4 Date: Tue, 4 Aug 2026 17:10:37 -0700 Subject: [PATCH 2/5] Rename MXC telemetry event identities Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7285baea-5254-4b6f-adbf-8e51cfe8e85c --- README.md | 39 ++++++++++++++--- docs/telemetry/telemetry-consent-design.md | 6 +-- docs/telemetry/telemetry.md | 18 +++++--- src/core/wxc_common/src/telemetry/events.rs | 14 +++--- src/core/wxc_common/src/telemetry/mod.rs | 48 ++++++++++----------- src/mxc_telemetry/src/lib.rs | 12 +++--- 6 files changed, 85 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index f14aa0efe..ede97a41d 100644 --- a/README.md +++ b/README.md @@ -229,17 +229,36 @@ wxc-exec.exe --audit policy.json ## Telemetry (Experimental) -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 (`Execution` and `Error`) 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 is **experimental** and requires: 1. The `--experimental` CLI flag 2. `"experimental": { "telemetry": { "enabled": true } }` in the JSON config +3. Explicit per-user telemetry consent on Windows +4. An administrative policy that permits collection, when a policy is configured + +The configuration flag is an additional per-run opt-in; it cannot grant consent +or bypass an administrative block. Telemetry remains off unless every applicable +gate is open. MXC does not use the Windows Diagnostics & feedback setting as a +substitute for application consent. On non-Windows platforms, all telemetry functions are no-ops. ### Data Collection -The software may collect information about you and your use of the software and send it to Microsoft. Microsoft may use this information to provide services and improve our products and services. You may turn off the telemetry as described in the repository. There are also some features in the software that may enable you and Microsoft to collect data from users of your applications. If you use these features, you must comply with applicable law, including providing appropriate notices to users of your applications together with a copy of Microsoft's privacy statement. Our privacy statement is located at https://go.microsoft.com/fwlink/?LinkID=824704. You can learn more about data collection and use in the help documentation and our privacy statement. Your use of the software operates as your consent to these practices. +The software may collect information about you and your use of the software and +send it to Microsoft when telemetry is enabled and all consent and policy gates +permit collection. Microsoft may use this information to provide services and +improve our products and services. Telemetry is off by default, and MXC does +not treat use of the software as consent. On Windows, the host application must +provide an appropriate notice and obtain explicit user consent before enabling +telemetry, and must provide a way to review or revoke that choice. If you use +these features to collect data from users of your applications, you must comply +with applicable law, including providing appropriate notices to your users +together with a copy of Microsoft's privacy statement. Our privacy statement +is located at https://go.microsoft.com/fwlink/?LinkID=824704. You can learn +more about data collection and use in the help documentation and our privacy +statement. #### How to turn telemetry off @@ -248,13 +267,23 @@ Telemetry is **off by default**. MXC emits telemetry only when **both** of the f 1. The `--experimental` CLI flag is passed, **and** 2. `"experimental": { "telemetry": { "enabled": true } }` is present in the JSON config. -Omitting either (the default) turns telemetry off entirely. On non-Windows platforms all telemetry functions are no-ops. +Those settings are necessary but not sufficient: on Windows, explicit +per-user consent and an administrative policy that permits collection are also +required. Omitting any required gate (the default) turns telemetry off +entirely. On non-Windows platforms all telemetry functions are no-ops and +consent is not applicable. #### 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. **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 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 the `Execution` and `Error` events from the `Microsoft.MXC` provider to Microsoft through the UTC pipeline when telemetry is enabled. **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 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. 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. The SDKs expose consent status and grant/revoke +operations but do not render a consent dialog; applications built with the SDK +are responsible for providing appropriate telemetry notices and consent +controls to their 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-consent-design.md b/docs/telemetry/telemetry-consent-design.md index 3d40e3868..320f26344 100644 --- a/docs/telemetry/telemetry-consent-design.md +++ b/docs/telemetry/telemetry-consent-design.md @@ -68,7 +68,7 @@ Windows app or SDK must. This design applies the same pillars end-to-end: | **Control** | The user (or the agent acting on their behalf) can flip the flag at any time, as many times as they like — no re-install, no support ticket. | | **Transparency** | A `status` query is always available and cheap (local file read, no network call) so any consumer can show the current state and link to [`telemetry.md`](telemetry.md) describing exactly what is collected. | | **No dark patterns** | Denying is exactly as easy as granting; MXC does not nag on every run once a choice has been made; the consent primitives never bias the wording or defaults toward "on". | -| **Least privilege / data minimization** | Reuses the existing bounded, PII-scrubbed event schema (`MXC.Execution` / `MXC.Error`, see `telemetry.md`) — this spec changes *whether* those events fire, never *what* they contain. | +| **Least privilege / data minimization** | Reuses the existing bounded, PII-scrubbed event schema (`Execution` / `Error` from the `Microsoft.MXC` provider, see `telemetry.md`) — this spec changes *whether* those events fire, never *what* they contain. | | **Fail closed** | Any ambiguous state — missing file, corrupt file, unreadable file, unknown platform — resolves to **not collecting**, never to collecting. | | **Platform honesty** | Non-Windows builds do not merely default the flag to "off" — the consent module does not compile in on non-Windows targets, so there is no code path, storage file, or API pretending consent is meaningful where MXC cannot and does not collect anything. | @@ -541,7 +541,8 @@ opposite order. the Windows policy "doesn't apply to any additional apps installed by your organization", and the supported OS evaluation APIs deliberately fold in the user's Settings-app choice, which MXC must not consume. -- Any change to the *content* of `MXC.Execution` / `MXC.Error` events — +- Any change to the *content* of `Execution` / `Error` events from the + `Microsoft.MXC` provider — this spec only changes the gate in front of the existing, already PII-reviewed schema. - Linux/macOS telemetry of any kind — explicitly and permanently not a goal. @@ -652,4 +653,3 @@ without one. debug build, so their consent/policy coverage is debug-only. Tracked as [#691](https://github.com/microsoft/mxc/issues/691). - diff --git a/docs/telemetry/telemetry.md b/docs/telemetry/telemetry.md index 4f3d11307..41bcd2488 100644 --- a/docs/telemetry/telemetry.md +++ b/docs/telemetry/telemetry.md @@ -61,7 +61,11 @@ shared Part C custom event fields: ## Events -### MXC.Execution +The provider-qualified uploaded event identities are +`Microsoft.MXC/Execution` and `Microsoft.MXC/Error`. `Microsoft.MXC` is the +TraceLogging provider name; `Execution` and `Error` are the event names. + +### Execution Emitted when a one-shot execution completes (success or failure). It is also emitted on early-exit failures in the one-shot executors — configuration, @@ -70,9 +74,9 @@ result (with `mxc.exit_code` = 1 and `mxc.outcome` = `failure`). The state-aware lifecycle (`provision` / `start` / `exec` / `stop` / `deprovision`) is also instrumented: each dispatched phase emits one -`MXC.Execution` tagged with `mxc.phase`. Non-`exec` phases and `exec` dry-runs +`Execution` tagged with `mxc.phase`. Non-`exec` phases and `exec` dry-runs report success with `mxc.exit_code` = 0; a completed `exec` reports the sandbox -process exit code; a dispatch error reports `failure` plus an `MXC.Error`. As in +process exit code; a dispatch error reports `failure` plus an `Error`. As in the one-shot path, a clean non-zero sandbox exit is not treated as an MXC error. | Field | Type | Description | @@ -96,7 +100,7 @@ Emitted on execution errors. | `__TlgCV__` | string | Microsoft Correlation Vector (MS-CV) — the lifecycle correlation key (see [Correlating a lifecycle](#correlating-a-lifecycle)); empty for one-shot executions | > **No free-form error text is emitted.** Error messages can contain paths, -> usernames, or credentials, so `MXC.Error` deliberately carries only the +> usernames, or credentials, so `Error` deliberately carries only the > bounded `error_type` category and the numeric `exit_code` — never the > message string itself. @@ -188,7 +192,7 @@ per-phase executor processes, which otherwise share no state. When telemetry is active, the executors install a global [`std::panic::set_hook`] handler — both the one-shot executors and the state-aware path (`run_state_aware_main`). If any thread panics, the hook emits -a failure `MXC.Execution` plus an `MXC.Error` categorised as `internal_error` +a failure `Execution` plus an `Error` categorised as `internal_error` (with `mxc.exit_code` = 101, the conventional Rust panic/abort exit code), attributed to the containment backend recorded at telemetry init and, on the state-aware path, the `mxc.phase` in progress. Consistent @@ -205,7 +209,7 @@ hook, so the default stderr backtrace still prints. > runner does this for container-cleanup safety), the panic hook still fires > during unwinding and records the crash event with the `101` sentinel exit > code, then claims the exactly-once terminal-emit slot. The recovered -> `MXC.Execution` completion event is therefore suppressed, so telemetry reports +> `Execution` completion event is therefore suppressed, so telemetry reports > `mxc.exit_code` = 101 even though the recovered process ultimately exits with a > different code (`-1`). The `101` here is a "a panic occurred" sentinel, not a > claim about the observed process exit code; `outcome` and `error_type` remain @@ -215,7 +219,7 @@ hook, so the default stderr backtrace still prints. ### Cancellation telemetry (console control handler) On Windows, when telemetry is active, `wxc-exec`'s console control handler emits -a failure `MXC.Execution` plus an `MXC.Error` categorised as `cancelled` when the +a failure `Execution` plus an `Error` categorised as `cancelled` when the operator interrupts a run (Ctrl-C, console close, or a system shutdown/logoff). The reported `mxc.exit_code` is 130 (the conventional "terminated by Ctrl-C" code, 128 + SIGINT) — a bounded attribution sentinel, since the OS ultimately diff --git a/src/core/wxc_common/src/telemetry/events.rs b/src/core/wxc_common/src/telemetry/events.rs index c4c011bbe..ada0e9db9 100644 --- a/src/core/wxc_common/src/telemetry/events.rs +++ b/src/core/wxc_common/src/telemetry/events.rs @@ -63,7 +63,7 @@ pub struct TelemetryContext<'a> { pub correlation_vector: &'a str, } -/// Data for an MXC.Execution ETW event. +/// Data for an Execution ETW event. pub struct ExecutionEvent<'a> { pub backend: &'a str, pub exit_code: i32, @@ -82,7 +82,7 @@ pub struct ExecutionEvent<'a> { pub correlation_vector: &'a str, } -/// Log an MXC.Execution ETW event. +/// Log an Execution ETW event. /// /// Delegates to the `mxc_telemetry` provider which adds common fields /// (Version, Channel, IsDebugging, UTCReplace_AppSessionGuid). @@ -103,7 +103,7 @@ pub fn log_execution(event: &ExecutionEvent<'_>) { test_sink::record_execution(event); } -/// Log an MXC.Error ETW event. +/// Log an Error ETW event. /// /// To avoid leaking PII (paths, usernames, credentials embedded in error /// strings), MXC deliberately does **not** emit the free-form error message. @@ -134,7 +134,7 @@ pub(super) mod test_sink { use std::cell::Cell; use std::sync::Mutex; - /// Owned copy of an `MXC.Execution` record as captured for a test. + /// Owned copy of an `Execution` record as captured for a test. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CapturedExecution { pub backend: String, @@ -146,7 +146,7 @@ pub(super) mod test_sink { pub correlation_vector: String, } - /// Owned copy of an `MXC.Error` record as captured for a test. + /// Owned copy of an `Error` record as captured for a test. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CapturedError { pub backend: String, @@ -182,12 +182,12 @@ pub(super) mod test_sink { ERRORS.lock().unwrap_or_else(|e| e.into_inner()).clear(); } - /// Drain and return the captured `MXC.Execution` records. + /// Drain and return the captured `Execution` records. pub fn take_executions() -> Vec { std::mem::take(&mut *EXECUTIONS.lock().unwrap_or_else(|e| e.into_inner())) } - /// Drain and return the captured `MXC.Error` records. + /// Drain and return the captured `Error` records. pub fn take_errors() -> Vec { std::mem::take(&mut *ERRORS.lock().unwrap_or_else(|e| e.into_inner())) } diff --git a/src/core/wxc_common/src/telemetry/mod.rs b/src/core/wxc_common/src/telemetry/mod.rs index bf242e24e..97e909ce2 100644 --- a/src/core/wxc_common/src/telemetry/mod.rs +++ b/src/core/wxc_common/src/telemetry/mod.rs @@ -80,7 +80,7 @@ static PROCESS_CORRELATION_VECTOR: Mutex> = Mutex::new(None); /// process. The best-effort out-of-band paths (panic hook, cancellation /// handler) can race the main thread's normal completion emit; this guard makes /// emission exactly-once so a single dispatch never yields duplicate -/// `MXC.Execution` records. +/// `Execution` records. static HAS_EMITTED: AtomicBool = AtomicBool::new(false); #[cfg(test)] @@ -232,8 +232,8 @@ fn classify_failure(phase: &FailurePhase) -> FailureReason { /// down. No-op when `active` is `false`. /// /// This is the single shared emit path for the `wxc` and `lxc` executors: -/// it records an `MXC.Execution` event and, for failures that carry an error -/// message, an `MXC.Error` event (category + exit code only — never the +/// it records an `Execution` event and, for failures that carry an error +/// message, an `Error` event (category + exit code only — never the /// message text), then calls [`shutdown`]. pub fn emit_completion( active: bool, @@ -290,8 +290,8 @@ pub fn emit_completion( /// /// One-shot executors validate configuration and select a backend before /// running; failures there call `process::exit` directly and would otherwise -/// bypass [`emit_completion`] entirely. This records an `MXC.Execution` event -/// (exit code 1, `failure` outcome) plus an `MXC.Error` event carrying the +/// bypass [`emit_completion`] entirely. This records an `Execution` event +/// (exit code 1, `failure` outcome) plus an `Error` event carrying the /// bounded `reason` category and exit code, so config/policy/init failures are /// observable. `duration_ms` is reported as `0` because no execution occurred. pub fn emit_early_exit(active: bool, containment: &ContainmentBackend, reason: FailureReason) { @@ -443,7 +443,7 @@ pub fn install_panic_hook() { })); } -/// Build the `MXC.Execution` event for an out-of-band crash/cancellation. Pure +/// Build the `Execution` event for an out-of-band crash/cancellation. Pure /// (no ETW I/O) so the exit-code/reason/attribution mapping can be unit-tested. fn crash_event<'a>( ctx: TelemetryContext<'a>, @@ -462,7 +462,7 @@ fn crash_event<'a>( } /// The pair of events an out-of-band crash/cancellation emits: one failure -/// `MXC.Execution` and one `MXC.Error`, both attributed to the same backend, +/// `Execution` and one `Error`, both attributed to the same backend, /// phase, exit code, and reason. struct CrashTelemetry<'a> { execution: ExecutionEvent<'a>, @@ -500,7 +500,7 @@ fn emit_crash(ctx: TelemetryContext<'_>, exit_code: i32, reason: FailureReason) /// /// Guarded by [`mxc_telemetry::is_active`], so it is a cheap no-op when /// telemetry is disabled or the provider is already shut down. It records a -/// failure `MXC.Execution` and an `MXC.Error` categorised as +/// failure `Execution` and an `Error` categorised as /// [`FailureReason::InternalError`], attributed to the process backend stashed /// by [`set_process_context`] and the phase stashed by [`set_process_phase`]. /// @@ -529,7 +529,7 @@ pub fn emit_panic() { /// /// Guarded by [`mxc_telemetry::is_active`], so it is a cheap no-op when /// telemetry is disabled or already shut down. It records a failure -/// `MXC.Execution` and an `MXC.Error` categorised as [`FailureReason::Cancelled`], +/// `Execution` and an `Error` categorised as [`FailureReason::Cancelled`], /// attributed to the process backend stashed by [`set_process_context`] and the /// phase stashed by [`set_process_phase`]. /// @@ -573,7 +573,7 @@ fn classify_mxc_error(err: &MxcError) -> FailureReason { } /// The telemetry a completed state-aware dispatch should emit: one -/// `MXC.Execution`, plus an optional `MXC.Error` category when the dispatch was +/// `Execution`, plus an optional `Error` category when the dispatch was /// an MXC infrastructure failure. Pure (no ETW I/O) so the outcome→event mapping /// can be unit-tested deterministically without an active provider. struct StateAwareEvents<'a> { @@ -611,7 +611,7 @@ fn plan_state_aware<'a>( duration_ms, // A non-zero guest exit is a faithfully propagated sandbox // exit code, not an MXC infrastructure error — leave the - // reason unset and emit no MXC.Error (mirrors one-shot + // reason unset and emit no Error (mirrors one-shot // emit_completion). failure_reason: None, phase: ctx.phase, @@ -650,10 +650,10 @@ fn plan_state_aware<'a>( /// This is the state-aware counterpart to [`emit_completion`]. Outcome mapping: /// - [`DispatchOutcome::Envelope`] (non-exec phases and exec dry-run) — success, /// exit code 0. -/// - [`DispatchOutcome::ExecCompleted`] — mirrors one-shot: an `MXC.Execution` +/// - [`DispatchOutcome::ExecCompleted`] — mirrors one-shot: an `Execution` /// with the sandbox exit code. A clean non-zero *sandbox* exit is not an MXC -/// failure, so no `MXC.Error` is emitted. -/// - `Err(MxcError)` — an `MXC.Execution` failure plus an `MXC.Error` carrying +/// failure, so no `Error` is emitted. +/// - `Err(MxcError)` — an `Execution` failure plus an `Error` carrying /// the [`classify_mxc_error`] category. /// /// Terminal path (`run_state_aware_main` exits immediately after), so it calls @@ -916,7 +916,7 @@ mod tests { fn emit_panic_active_captures_execution_and_error() { // Drive the real emit glue (globals read → active guard → paired write) // with the provider forced active and the capture sink installed, then - // assert the exact MXC.Execution + MXC.Error records a panic produces. + // assert the exact Execution + Error records a panic produces. let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); reset_for_test(); events::test_sink::install(); @@ -928,7 +928,7 @@ mod tests { emit_panic(); let execs = events::test_sink::take_executions(); - assert_eq!(execs.len(), 1, "panic emits exactly one MXC.Execution"); + assert_eq!(execs.len(), 1, "panic emits exactly one Execution"); let exec = &execs[0]; assert_eq!(exec.backend, "isolation_session"); assert_eq!(exec.exit_code, PANIC_EXIT_CODE); @@ -938,7 +938,7 @@ mod tests { assert_eq!(exec.correlation_vector, "iso:wxc-abcd"); let errors = events::test_sink::take_errors(); - assert_eq!(errors.len(), 1, "panic emits exactly one MXC.Error"); + assert_eq!(errors.len(), 1, "panic emits exactly one Error"); let error = &errors[0]; assert_eq!(error.backend, "isolation_session"); assert_eq!(error.error_type, FailureReason::InternalError); @@ -998,12 +998,12 @@ mod tests { assert_eq!( events::test_sink::take_executions().len(), 1, - "second emit must not add an MXC.Execution" + "second emit must not add an Execution" ); assert_eq!( events::test_sink::take_errors().len(), 1, - "second emit must not add an MXC.Error" + "second emit must not add an Error" ); reset_for_test(); @@ -1014,7 +1014,7 @@ mod tests { // The exactly-once slot (`HAS_EMITTED`) is concurrency-critical: the // out-of-band panic/cancellation paths race the main completion emit, // and the guard is what keeps a single dispatch from producing - // duplicate MXC.Execution records. Lock the global state, reset to a + // duplicate Execution records. Lock the global state, reset to a // known baseline, and assert claim-once semantics end-to-end. let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); reset_for_test(); @@ -1183,7 +1183,7 @@ mod tests { panic.execution.failure_reason, Some(FailureReason::InternalError) ); - // The MXC.Error carries the same reason/exit code as the execution event. + // The Error carries the same reason/exit code as the execution event. assert_eq!(panic.error, FailureReason::InternalError); assert_eq!(panic.exit_code, PANIC_EXIT_CODE); @@ -1251,7 +1251,7 @@ mod tests { assert!(zero_plan.error.is_none()); // Non-zero guest exit → failure with the propagated exit code, but - // NO MXC.Error (a faithfully-propagated script exit, not an MXC + // NO Error (a faithfully-propagated script exit, not an MXC // failure). let nonzero = Ok(DispatchOutcome::ExecCompleted { exit_code: 42 }); let nonzero_plan = plan_state_aware(ctx, &nonzero, 3); @@ -1262,7 +1262,7 @@ mod tests { assert!(nonzero_plan.execution.failure_reason.is_none()); assert!(nonzero_plan.error.is_none()); - // MxcError → failure / exit 1 / classified MXC.Error. + // MxcError → failure / exit 1 / classified Error. let err = Err(MxcError::backend_unavailable("no host")); let err_plan = plan_state_aware(ctx, &err, 5); assert_eq!(err_plan.execution.phase, phase); @@ -1331,7 +1331,7 @@ mod tests { reset_for_test(); events::test_sink::install(); - // Provision-style success envelope → one MXC.Execution, no MXC.Error. + // Provision-style success envelope → one Execution, no Error. let envelope = Ok(DispatchOutcome::Envelope(serde_json::json!({}))); emit_state_aware( true, diff --git a/src/mxc_telemetry/src/lib.rs b/src/mxc_telemetry/src/lib.rs index a59dfefad..dd37abf39 100644 --- a/src/mxc_telemetry/src/lib.rs +++ b/src/mxc_telemetry/src/lib.rs @@ -130,7 +130,7 @@ mod provider { } } - /// Emit an `MXC.Execution` ETW event. + /// Emit an `Execution` ETW event. /// /// `phase` is the state-aware lifecycle phase that produced this event — /// one of `provision|start|exec|stop|deprovision`. It is empty for one-shot @@ -165,10 +165,10 @@ mod provider { tracelogging::write_event!( MXC_PROVIDER, - "MXC.Execution", + "Execution", // Informational: every completion (success or failure) is a routine // "what happened" record, not a fault. Severity is reserved for - // MXC.Error (Warning) and any future provider malfunction. + // Error (Warning) and any future provider malfunction. level(Informational), keyword(MICROSOFT_KEYWORD_MEASURES), u64("PartA_PrivTags", &PDT_PRODUCT_AND_SERVICE_USAGE), @@ -195,7 +195,7 @@ mod provider { ); } - /// Emit an `MXC.Error` ETW event. + /// Emit an `Error` ETW event. /// /// By design this event carries **no free-form error text** — only the /// bounded `error_type` category and the process `exit_code`. This keeps @@ -223,7 +223,7 @@ mod provider { tracelogging::write_event!( MXC_PROVIDER, - "MXC.Error", + "Error", // Warning, not Error/Critical: this reports an expected operational // failure of a *sandboxed run* (e.g. the user's script failed, a // backend was unavailable, a missing/rejected config) — not a @@ -245,7 +245,7 @@ mod provider { // State-aware lifecycle phase (provision|start|exec|stop| // deprovision); empty for one-shot executions. str8("mxc.phase", phase), - // MS-CV under `__TlgCV__` (see MXC.Execution); empty for one-shot. + // MS-CV under `__TlgCV__` (see Execution); empty for one-shot. str8("__TlgCV__", correlation_vector), ); } From a7a801f5a175ad6f9c2e7fac7add1f88e1863381 Mon Sep 17 00:00:00 2001 From: RamonArjona4 Date: Tue, 4 Aug 2026 17:31:17 -0700 Subject: [PATCH 3/5] Address telemetry Copilot review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7285baea-5254-4b6f-adbf-8e51cfe8e85c --- scripts/check-telemetry-policy-parity.js | 17 ++++++++++++++--- src/core/lxc/src/main.rs | 2 +- src/core/mxc_darwin/src/main.rs | 2 +- src/core/wxc/src/main.rs | 2 +- src/ffi/mxc_ffi/Cargo.toml | 1 - src/ffi/mxc_ffi/src/lib.rs | 10 +++++----- 6 files changed, 22 insertions(+), 12 deletions(-) diff --git a/scripts/check-telemetry-policy-parity.js b/scripts/check-telemetry-policy-parity.js index ff187141d..76a539a48 100644 --- a/scripts/check-telemetry-policy-parity.js +++ b/scripts/check-telemetry-policy-parity.js @@ -64,7 +64,8 @@ if (rustStates.size === 0) { // --- C#: the ParsePolicyState switch -------------------------------------- // Every state except the fail-closed default must appear as a literal arm; // the default arm is what maps everything else (including `"blocked"`) to -// Blocked, so `blocked` is expected to be absent from the explicit arms. +// Blocked. The default may return Blocked directly or through the helper +// that records an actionable diagnostic before failing closed. const csharpSrc = readFileSync(csharpPath, "utf8"); const parseBody = csharpSrc.match( /ParsePolicyState\(string\? value\) => value switch\s*\{([\s\S]*?)\};/ @@ -77,14 +78,24 @@ const csharpStates = new Set(); for (const m of parseBody[1].matchAll(/"([a-z-]+)"\s*=>/g)) { csharpStates.add(m[1]); } -const csharpDefault = /_\s*=>\s*TelemetryPolicyState\.Blocked/.test( +const csharpDefault = /_\s*=>\s*(?:TelemetryPolicyState\.Blocked|UnrecognizedPolicyState\(value\))/.test( parseBody[1] ); if (!csharpDefault) { errors.push( - "C# ParsePolicyState must fail closed with `_ => TelemetryPolicyState.Blocked`" + "C# ParsePolicyState must fail closed with a direct Blocked result or the UnrecognizedPolicyState helper" ); } +if (/UnrecognizedPolicyState\(value\)/.test(parseBody[1])) { + const helperBody = csharpSrc.match( + /UnrecognizedPolicyState\(string\? value\)\s*\{([\s\S]*?)\n\s*\}/ + ); + if (!helperBody || !/return\s+TelemetryPolicyState\.Blocked\s*;/.test(helperBody[1])) { + errors.push( + "C# UnrecognizedPolicyState must return TelemetryPolicyState.Blocked" + ); + } +} // The default arm covers "blocked", so treat it as handled. csharpStates.add("blocked"); diff --git a/src/core/lxc/src/main.rs b/src/core/lxc/src/main.rs index e71c8ed2d..372049b4e 100644 --- a/src/core/lxc/src/main.rs +++ b/src/core/lxc/src/main.rs @@ -92,7 +92,7 @@ struct Cli { telemetry_consent_revoke: bool, /// Unused on Linux; accepted only for CLI-surface parity. - #[arg(long = "telemetry-consent-source")] + #[arg(long = "telemetry-consent-source", allow_hyphen_values = true)] telemetry_consent_source: Option, } diff --git a/src/core/mxc_darwin/src/main.rs b/src/core/mxc_darwin/src/main.rs index 6f7c8aae3..8cb8d96b4 100644 --- a/src/core/mxc_darwin/src/main.rs +++ b/src/core/mxc_darwin/src/main.rs @@ -80,7 +80,7 @@ struct Cli { telemetry_consent_revoke: bool, /// Unused on macOS; accepted only for CLI-surface parity. - #[arg(long = "telemetry-consent-source")] + #[arg(long = "telemetry-consent-source", allow_hyphen_values = true)] telemetry_consent_source: Option, } diff --git a/src/core/wxc/src/main.rs b/src/core/wxc/src/main.rs index ac38f9d5c..3acfb5909 100644 --- a/src/core/wxc/src/main.rs +++ b/src/core/wxc/src/main.rs @@ -136,7 +136,7 @@ struct Cli { /// `--telemetry-consent-revoke` decision (e.g. `"prompt"`, /// `"settings-toggle"`). Defaults to `"cli"` when omitted. Never /// transmitted anywhere; local diagnostic metadata only. - #[arg(long = "telemetry-consent-source")] + #[arg(long = "telemetry-consent-source", allow_hyphen_values = true)] telemetry_consent_source: Option, /// Windows Sandbox: tear down a running WSB VM that mxc cannot prove it diff --git a/src/ffi/mxc_ffi/Cargo.toml b/src/ffi/mxc_ffi/Cargo.toml index f8b2dc5cb..8b4bd69d2 100644 --- a/src/ffi/mxc_ffi/Cargo.toml +++ b/src/ffi/mxc_ffi/Cargo.toml @@ -14,7 +14,6 @@ crate-type = ["cdylib", "staticlib", "lib"] [dependencies] mxc-sdk = { workspace = true } -wxc_common = { workspace = true } serde_json = { workspace = true } [features] diff --git a/src/ffi/mxc_ffi/src/lib.rs b/src/ffi/mxc_ffi/src/lib.rs index f2d69c9a0..761487918 100644 --- a/src/ffi/mxc_ffi/src/lib.rs +++ b/src/ffi/mxc_ffi/src/lib.rs @@ -414,7 +414,7 @@ pub extern "C" fn mxc_version() -> *const c_char { // See docs/telemetry/telemetry-consent-design.md. MXC only ever collects // telemetry on Windows, and only when this persisted, MXC-owned consent flag // is granted — never derived from any Windows-level diagnostics setting. -// `wxc_common::telemetry::consent` compiles a non-Windows stub that always +// `mxc_sdk::telemetry` compiles a non-Windows stub that always // reports "not-applicable" and rejects writes, so these entry points behave // identically here across platforms: callers get one C ABI regardless of // host OS, and the platform gate lives in exactly one place (the Rust @@ -434,7 +434,7 @@ pub extern "C" fn mxc_version() -> *const c_char { /// `out_utf8` must be null or point to writable `*mut c_char`-sized storage. #[no_mangle] pub unsafe extern "C" fn mxc_telemetry_get_consent(out_utf8: *mut *mut c_char) -> i32 { - let result = catch_unwind(|| wxc_common::telemetry::consent::get_consent().as_str()); + let result = catch_unwind(|| mxc_sdk::telemetry::get_consent().as_str()); let state_str = match result { Ok(s) => s, Err(p) => { @@ -479,7 +479,7 @@ pub unsafe extern "C" fn mxc_telemetry_set_consent( let source = source.to_string(); let granted = granted != 0; - let result = catch_unwind(|| wxc_common::telemetry::consent::set_consent(granted, &source)); + let result = catch_unwind(|| mxc_sdk::telemetry::set_consent(granted, &source)); match result { Ok(Ok(())) => MXC_STATUS_SUCCESS, Ok(Err(e)) => { @@ -521,7 +521,7 @@ pub unsafe extern "C" fn mxc_telemetry_needs_consent_prompt(out_needs_prompt: *m return MXC_STATUS_NULL_ARGUMENT; } - let needs_prompt = match catch_unwind(wxc_common::telemetry::consent::needs_consent_prompt) { + let needs_prompt = match catch_unwind(mxc_sdk::telemetry::needs_consent_prompt) { Ok(b) => b, Err(p) => { report_panic("mxc_telemetry_needs_consent_prompt", &*p); @@ -559,7 +559,7 @@ pub unsafe extern "C" fn mxc_telemetry_needs_consent_prompt(out_needs_prompt: *m /// `out_utf8` must be null or point to writable `*mut c_char`-sized storage. #[no_mangle] pub unsafe extern "C" fn mxc_telemetry_get_policy(out_utf8: *mut *mut c_char) -> i32 { - let result = catch_unwind(|| wxc_common::telemetry::policy::get_policy().as_str()); + let result = catch_unwind(|| mxc_sdk::telemetry::get_policy().as_str()); let state_str = match result { Ok(s) => s, Err(p) => { From ced8d4f08acb6c60534f09e66e2353700a6571ef Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Wed, 5 Aug 2026 12:46:11 -0700 Subject: [PATCH 4/5] fix: address remaining Copilot review comments on PR 706 - NativeLibraryResolver.cs: probe the Cargo profile-specific dev target dirs (matching the C# build's own DEBUG/RELEASE config) before the generic baseDir/runtimes//native candidates. build.bat --debug stages a debug mxc_ffi.dll into that generic runtimes location, so a Release build whose output dir still holds a leftover debug DLL from a prior debug build would otherwise load it ahead of a freshly built release binary, re-enabling debug-only overrides (e.g. the LOCALAPPDATA consent-store override) in a Release build. - consent.rs: open the process token with TOKEN_QUERY | TOKEN_IMPERSONATE instead of TOKEN_QUERY alone. SHGetKnownFolderPath duplicates the handed-in token into an impersonation token internally and requires both rights; on systems that enforce that contract a TOKEN_QUERY-only handle made LocalAppData resolution fail, so consent always read as Undetermined and every grant/revoke failed closed. - main.rs: move the --telemetry-consent-{status,grant,revoke} fast path to run immediately after CLI parsing, before --force-reclaim env propagation and recover_orphaned_state(). The consent fast path is a read-only/local-file query with a 5-second client-side timeout; DACL recovery scans state files and may restore host DACLs, so running it first could time out the consent query (suppressing the prompt) or let a status-only read unexpectedly mutate filesystem ACLs. This now matches the Linux/macOS executors, where the same fast path already runs unconditionally first. Note: scripts/check-telemetry-policy-parity.js was already fixed for the C# ParsePolicyState/UnrecognizedPolicyState helper pattern in the prior commit (a7a801f) and now runs clean; no further change needed. Verified: cargo test --workspace (all crates pass), cargo clippy --workspace --all-targets -- -D warnings (clean), cargo fmt --all --check (clean), dotnet build + dotnet test Microsoft.Mxc.Sdk.slnx (61/61 pass), node scripts/check-telemetry-policy-parity.js (OK). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Native/NativeLibraryResolver.cs | 23 ++++++++++++++----- src/core/wxc/src/main.rs | 21 +++++++++++------ src/core/wxc_common/src/telemetry/consent.rs | 20 +++++++++++++--- 3 files changed, 48 insertions(+), 16 deletions(-) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/Native/NativeLibraryResolver.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/Native/NativeLibraryResolver.cs index 524149cd8..7ad5fdbc8 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/Native/NativeLibraryResolver.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/Native/NativeLibraryResolver.cs @@ -100,14 +100,19 @@ private static IEnumerable CandidatePaths() } var baseDir = AppContext.BaseDirectory; - yield return Path.Combine(baseDir, file); - yield return Path.Combine(baseDir, "runtimes", RuntimeInformation.RuntimeIdentifier, "native", file); // Dev layout: walk up looking for the Cargo target dir. Probe the - // Cargo profile matching this assembly's build configuration first: - // a Release C# build binding against a stale debug mxc_ffi would pick - // up debug-only behaviour (e.g. the LOCALAPPDATA consent-store - // override), which is exactly what a Release build must not do. + // Cargo profile matching this assembly's build configuration first, + // and *before* the generic baseDir/runtimes candidates below: those + // generic locations are also where `build.bat --debug` stages its + // debug `mxc_ffi.dll` (into `runtimes//native`, which the csproj + // then copies to the output directory), so a Release build whose + // output dir still holds a leftover debug DLL from a prior debug + // build must not pick it up before checking for a freshly built + // release binary in the Cargo target dir. A Release C# build binding + // against a stale debug mxc_ffi would pick up debug-only behaviour + // (e.g. the LOCALAPPDATA consent-store override), which is exactly + // what a Release build must not do. for (var dir = new DirectoryInfo(baseDir); dir is not null; dir = dir.Parent) { #if DEBUG @@ -121,6 +126,12 @@ private static IEnumerable CandidatePaths() yield return Path.Combine(dir.FullName, "src", "target", "release", file); #endif } + + // Generic fallbacks last: only reached when no Cargo target dir was + // found (e.g. a packaged NuGet consumer with no local `src/` checkout), + // so there is no profile-specific candidate to prefer over them. + yield return Path.Combine(baseDir, file); + yield return Path.Combine(baseDir, "runtimes", RuntimeInformation.RuntimeIdentifier, "native", file); } private static string NativeFileName() diff --git a/src/core/wxc/src/main.rs b/src/core/wxc/src/main.rs index 3acfb5909..b17f112a7 100644 --- a/src/core/wxc/src/main.rs +++ b/src/core/wxc/src/main.rs @@ -799,6 +799,20 @@ fn install_dacl_ctrl_handler() { fn main() { let cli = Cli::parse().normalize_named_config_command(); + // --telemetry-consent-{status,grant,revoke}: administer the persisted + // consent flag and exit. Runs BEFORE `force_reclaim` env propagation and + // `recover_orphaned_state()` below (unlike a prior version of this + // function): this is a read-only/local-file fast path with a 5-second + // client-side timeout (Node's consent getter), so it must not be gated + // behind unrelated recovery work that scans state files and may restore + // host DACLs — that could both time out the query (suppressing the + // prompt) and let a plain status read unexpectedly mutate filesystem + // ACLs. Matches the Linux/macOS executors, where this fast path also + // runs unconditionally immediately after CLI parsing. + if handle_telemetry_consent_flags(&cli) { + return; + } + // Propagate --force-reclaim via the environment so it reaches both the // in-process one-shot reconcile and the detached daemon. Set before any // backend dispatch or daemon spawn. @@ -830,13 +844,6 @@ fn main() { Err(e) => eprintln!("DACL recovery failed: {e}"), } - // --telemetry-consent-{status,grant,revoke}: administer the persisted - // consent flag and exit. Run before --probe (cheapest possible fast - // path — no config parsing, no policy defaults needed). - if handle_telemetry_consent_flags(&cli) { - return; - } - // --probe is a detection-only fast path used by SDK // `getPlatformSupport()` on every first call. It does not spawn a // sandbox, never parks a DaclManager, and never calls into COM/WinRT. diff --git a/src/core/wxc_common/src/telemetry/consent.rs b/src/core/wxc_common/src/telemetry/consent.rs index 2e6613a37..e7ffafe26 100644 --- a/src/core/wxc_common/src/telemetry/consent.rs +++ b/src/core/wxc_common/src/telemetry/consent.rs @@ -204,7 +204,7 @@ mod platform { .clone() } - /// Opens the current **process** token with `TOKEN_QUERY`. + /// Opens the current **process** token with `TOKEN_QUERY | TOKEN_IMPERSONATE`. /// /// Passing `None` to `SHGetKnownFolderPath` would resolve against the /// calling *thread's* token — the impersonated one, if the host has @@ -213,9 +213,16 @@ mod platform { /// impersonating, which is the same class of attack the known-folder API /// is being used to prevent in the first place. Binding explicitly to the /// process token keeps the answer a stable property of the process. + /// + /// `SHGetKnownFolderPath` requires the token it is handed to be opened + /// with both `TOKEN_QUERY` *and* `TOKEN_IMPERSONATE` (it duplicates the + /// handle into an impersonation token internally); on systems that + /// enforce that contract, a `TOKEN_QUERY`-only handle makes resolution + /// fail, so consent would always read as `Undetermined` and every + /// grant/revoke would fail closed. fn process_token() -> Option { use windows::Win32::Foundation::HANDLE; - use windows::Win32::Security::TOKEN_QUERY; + use windows::Win32::Security::{TOKEN_IMPERSONATE, TOKEN_QUERY}; use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; let mut token = HANDLE::default(); @@ -223,7 +230,14 @@ mod platform { // release, and `token` is a valid out-pointer for the duration of the // call. On success the returned handle is immediately wrapped in // `OwnedHandle`, whose `Drop` closes it exactly once. - unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) }.ok()?; + unsafe { + OpenProcessToken( + GetCurrentProcess(), + TOKEN_QUERY | TOKEN_IMPERSONATE, + &mut token, + ) + } + .ok()?; Some(crate::process_util::OwnedHandle::new(token)) } From 18af030f2d409e88aed7a710e4cbec1aa968d2bc Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Mon, 10 Aug 2026 15:26:50 -0700 Subject: [PATCH 5/5] Promote telemetry to stable configuration Move telemetry to the top-level 0.8 configuration surface, wire SDK and FFI execution paths, preserve consent and policy gates, and reject the legacy experimental placement. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 18 +- docs/schema.md | 8 +- .../mxc-state-aware-sandbox-api.md | 2 +- docs/telemetry/telemetry-consent-design.md | 13 +- docs/telemetry/telemetry.md | 4 +- schemas/dev/mxc-config.schema.0.8.0-dev.json | 26 +-- .../MxcLifecycleTests.cs | 38 ++++ sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs | 57 ++++- sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs | 8 + .../Microsoft.Mxc.Sdk/StateAwareTypes.cs | 25 +++ sdk/dotnet/README.md | 8 +- sdk/node/README.md | 9 +- sdk/node/src/generated/wire.ts | 12 +- sdk/node/src/sandbox.ts | 15 +- sdk/node/src/state-aware-helper.ts | 15 +- sdk/node/src/state-aware-types.ts | 2 +- sdk/node/src/state-aware.ts | 6 + sdk/node/src/types.ts | 6 +- sdk/node/tests/unit/state-aware.test.ts | 20 ++ sdk/node/tests/unit/wire-conformance.test.ts | 5 + src/core/lxc/src/main.rs | 19 +- src/core/mxc-sdk/README.md | 11 +- src/core/mxc_darwin/src/main.rs | 19 +- src/core/mxc_engine/src/lib.rs | 197 +++++++++++++++++- src/core/mxc_engine/src/policy.rs | 15 +- src/core/mxc_engine/src/state_aware.rs | 140 ++++++++++++- src/core/wxc/src/main.rs | 59 ++---- src/core/wxc_common/src/config_parser.rs | 166 ++++++++++----- src/core/wxc_common/src/models.rs | 6 +- src/core/wxc_common/src/telemetry/mod.rs | 69 +++++- src/core/wxc_common/src/wire.rs | 9 +- src/ffi/mxc_ffi/src/lib.rs | 51 ++++- src/ffi/mxc_ffi/src/streaming.rs | 19 +- src/mxc_telemetry/src/lib.rs | 19 +- .../wxc_e2e_tests/tests/e2e_windows.rs | 2 +- tests/examples/28_telemetry_enabled.json | 6 +- .../scripts/run_telemetry_etw_smoke_test.ps1 | 6 +- 37 files changed, 885 insertions(+), 225 deletions(-) diff --git a/README.md b/README.md index af853657f..712625972 100644 --- a/README.md +++ b/README.md @@ -227,15 +227,14 @@ wxc-exec.exe --audit policy.json > **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 (Experimental) +## Telemetry MXC supports optional TraceLogging ETW telemetry for execution observability. When enabled, structured events (`Execution` and `Error`) 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 is **experimental** and requires: -1. The `--experimental` CLI flag -2. `"experimental": { "telemetry": { "enabled": true } }` in the JSON config -3. Explicit per-user telemetry consent on Windows -4. An administrative policy that permits collection, when a policy is configured +Telemetry requires: +1. Top-level `"telemetry": { "enabled": true }` in the JSON config +2. Explicit per-user telemetry consent on Windows +3. An administrative policy that permits collection, when a policy is configured The configuration flag is an additional per-run opt-in; it cannot grant consent or bypass an administrative block. Telemetry remains off unless every applicable @@ -262,10 +261,9 @@ statement. #### How to turn telemetry off -Telemetry is **off by default**. MXC emits telemetry only when **both** of the following are set, so no action is required to keep it disabled: - -1. The `--experimental` CLI flag is passed, **and** -2. `"experimental": { "telemetry": { "enabled": true } }` is present in the JSON config. +Telemetry is **off by default**. MXC emits telemetry only when top-level +`"telemetry": { "enabled": true }` is present in the JSON config, so no +action is required to keep it disabled. Those settings are necessary but not sufficient: on Windows, explicit per-user consent and an administrative policy that permits collection are also diff --git a/docs/schema.md b/docs/schema.md index d9cb3dc17..4bd7c8573 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -109,9 +109,9 @@ production configs and the dev schema when working on experimental features: "nestedPty": true, // Allow inner process to allocate its own pty (posix_openpt) "keychainAccess": false // Allow Keychain via securityd / trustd / cfprefsd / lsd.* }, - "telemetry": { // Telemetry (experimental, Windows only) - "enabled": true // Emit TraceLogging ETW events via pure Rust tracelogging crate - } + }, + "telemetry": { // Telemetry (Windows only) + "enabled": true // Emit TraceLogging ETW events via pure Rust tracelogging crate } } ``` @@ -123,7 +123,7 @@ production configs and the dev schema when working on experimental features: > request carrying either is rejected with a parse error. `correlationVector` is > the Microsoft Correlation Vector (MS-CV) seeded at `provision` and relayed by > the client onto later phases (emitted under the TraceLogging `__TlgCV__` field -> when experimental telemetry is enabled). The client relays the value verbatim; +> when telemetry is enabled). The client relays the value verbatim; > the executor validates it on each non-`provision` phase and *spins* a fresh > child element off a mutable base, passes an already-frozen vector through > unchanged, and reseeds a new base if the relayed value is missing or malformed. diff --git a/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md b/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md index 13667e429..32751f560 100644 --- a/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md +++ b/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md @@ -664,7 +664,7 @@ State-aware-only fields: | Field | Type | Required | Description | |---|---|---|---| | `phase` | `Phase` member | Yes | Discriminator. Absence means a one-shot request. | -| `correlationVector` | string | No. Relayed by the client onto non-`provision` phases; absent on `provision` (seeded by the executor). Rejected as a parse error on one-shot requests. | Microsoft Correlation Vector (MS-CV) seeded at `provision` and returned in its result; the client relays it verbatim into later phases so the lifecycle shares a telemetry base prefix (emitted under `__TlgCV__`). Each non-`provision` phase validates the relayed value and *spins* a fresh child element off a mutable base (keeping repeat invocations distinct), passes an already-frozen vector through unchanged, and reseeds a new base if it is absent or malformed. Ignored unless experimental telemetry is enabled. See [telemetry docs](../telemetry/telemetry.md#correlating-a-lifecycle). | +| `correlationVector` | string | No. Relayed by the client onto non-`provision` phases; absent on `provision` (seeded by the executor). Rejected as a parse error on one-shot requests. | Microsoft Correlation Vector (MS-CV) seeded at `provision` and returned in its result; the client relays it verbatim into later phases so the lifecycle shares a telemetry base prefix (emitted under `__TlgCV__`). Each non-`provision` phase validates the relayed value and *spins* a fresh child element off a mutable base (keeping repeat invocations distinct), passes an already-frozen vector through unchanged, and reseeds a new base if it is absent or malformed. Ignored unless telemetry is enabled. See [telemetry docs](../telemetry/telemetry.md#correlating-a-lifecycle). | | `process` | `ProcessConfig` | Required for `exec`; absent otherwise. | Cross-backend execution fields. | Cross-cutting fields available to state-aware (state-aware-only at top level — backends diff --git a/docs/telemetry/telemetry-consent-design.md b/docs/telemetry/telemetry-consent-design.md index 320f26344..8c9e3720f 100644 --- a/docs/telemetry/telemetry-consent-design.md +++ b/docs/telemetry/telemetry-consent-design.md @@ -10,9 +10,9 @@ MXC already has a fully-built ETW TraceLogging pipeline (`mxc_telemetry` + `wxc_common::telemetry`, see -[`telemetry.md`](telemetry.md)), gated behind `--experimental` and a -per-request JSON field, `experimental.telemetry.enabled`. Today that field is -the *entire* consent model, and the code says so explicitly +[`telemetry.md`](telemetry.md)), enabled per request by the top-level +`telemetry.enabled` field. Before this consent feature, that field was the +*entire* consent model, and the code said so explicitly (`wxc_common/src/telemetry/mod.rs`): > "Note: Consent is the SDK consumer's responsibility. MXC does not @@ -136,7 +136,7 @@ Telemetry emission (`wxc_common::telemetry::is_enabled`) becomes: effective = platform_is_windows && admin_policy_permits && persisted_consent == Granted - && request.experimental.telemetry.enabled != Some(false) + && request.telemetry.enabled == Some(true) ``` - Persisted consent is the **gate**. Without `Granted`, nothing fires, @@ -145,7 +145,7 @@ effective = platform_is_windows administrator can stop MXC collecting on a device, but an administrator permitting collection does not stand in for the user's own decision. See [`telemetry-policy.md`](telemetry-policy.md) for the full specification. -- The existing `experimental.telemetry.enabled` field becomes an +- The top-level `telemetry.enabled` field is an **explicit opt-in that can only subtract**: collection requires `true`, while omitting the field or setting `false` always disables it (a caller can always force telemetry off for one run, e.g. CI, a support @@ -436,7 +436,7 @@ exactly the flow described in §8. run in the existing Windows CI job)**: - Fresh machine (no file) ⇒ `Undetermined`, `is_enabled() == false`. - Grant ⇒ persists, re-read returns `Granted`, `is_enabled() == true` - (with `experimental.telemetry.enabled` set to `true`). + (with top-level `telemetry.enabled` set to `true`). - Grant + request omits `enabled` or sets it to `false` ⇒ `is_enabled() == false` (collection requires an explicit opt-in). - Deny ⇒ persists, `is_enabled() == false` even if request sets @@ -652,4 +652,3 @@ without one. tests, the C# and Node suites driving a native binary) only get them from a debug build, so their consent/policy coverage is debug-only. Tracked as [#691](https://github.com/microsoft/mxc/issues/691). - diff --git a/docs/telemetry/telemetry.md b/docs/telemetry/telemetry.md index 41bcd2488..5a3469db9 100644 --- a/docs/telemetry/telemetry.md +++ b/docs/telemetry/telemetry.md @@ -162,7 +162,7 @@ caller-supplied identity (e.g. a UPN embedded in an IsolationSession — a missing, empty, or malformed relayed value falls back to a fresh seed rather than panicking telemetry. `__TlgCV__` is empty for one-shot executions (which have no lifecycle to correlate) and for a crash during `provision` before the vector is -stashed. It is only computed and emitted when experimental telemetry is active, so +stashed. It is only computed and emitted when telemetry is active, so provision output is unchanged when telemetry is off. **Why a relayed random vector rather than a hashed `sandbox_id`.** An alternative @@ -238,7 +238,7 @@ free-form text. ## Consent Telemetry emission is gated by a **persisted, MXC-owned consent flag**, not -just the `experimental.telemetry.enabled` config field. See +just the top-level `telemetry.enabled` config field. See [`docs/telemetry/telemetry-consent-design.md`](telemetry-consent-design.md) for the full design; the summary: diff --git a/schemas/dev/mxc-config.schema.0.8.0-dev.json b/schemas/dev/mxc-config.schema.0.8.0-dev.json index 7ae3cc5bb..a91d29918 100644 --- a/schemas/dev/mxc-config.schema.0.8.0-dev.json +++ b/schemas/dev/mxc-config.schema.0.8.0-dev.json @@ -205,17 +205,6 @@ ], "description": "Seatbelt backend config (pre-promotion alias)." }, - "telemetry": { - "anyOf": [ - { - "$ref": "#/definitions/Telemetry" - }, - { - "type": "null" - } - ], - "description": "Telemetry configuration." - }, "test": { "anyOf": [ { @@ -718,7 +707,7 @@ "type": "object" }, "Telemetry": { - "description": "Telemetry configuration (`experimental.telemetry`).", + "description": "Telemetry configuration (`telemetry`).", "properties": { "enabled": { "description": "Explicit telemetry opt-in for this invocation. `true` = opt in (still subject to the user's consent and to administrative policy — it can never turn telemetry on for someone who has not consented), `false` = force off, omitted = off.", @@ -919,7 +908,7 @@ "description": "Containment backend to use for execution. Accepts abstract intents (`process`, `vm`) and concrete backends; the binary resolves intents to a concrete backend per host at run time." }, "correlationVector": { - "description": "Microsoft Correlation Vector (MS-CV) seeded at `provision` and returned in the provision result. The client relays it verbatim into every later state-aware phase so all phases of one lifecycle share a telemetry base prefix (emitted under `__TlgCV__`). The executor is the trust boundary: on each non-provision phase it validates the relayed value and *spins* a fresh child element off a mutable base (so multiple invocations of one phase stay distinct), passes an already-frozen vector through unchanged, and reseeds a brand-new base if the relayed value is absent or malformed — so a missing or hostile relay never reaches telemetry unvalidated. Ignored unless experimental telemetry is enabled; not valid on one-shot requests.", + "description": "Microsoft Correlation Vector (MS-CV) seeded at `provision` and returned in the provision result. The client relays it verbatim into every later state-aware phase so all phases of one lifecycle share a telemetry base prefix (emitted under `__TlgCV__`). The executor is the trust boundary: on each non-provision phase it validates the relayed value and *spins* a fresh child element off a mutable base (so multiple invocations of one phase stay distinct), passes an already-frozen vector through unchanged, and reseeds a brand-new base if the relayed value is absent or malformed — so a missing or hostile relay never reaches telemetry unvalidated. Ignored unless telemetry is enabled; not valid on one-shot requests.", "type": [ "string", "null" @@ -1042,6 +1031,17 @@ ], "description": "macOS Seatbelt backend configuration. Used when containment is `seatbelt`." }, + "telemetry": { + "anyOf": [ + { + "$ref": "#/definitions/Telemetry" + }, + { + "type": "null" + } + ], + "description": "Telemetry configuration." + }, "ui": { "anyOf": [ { diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcLifecycleTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcLifecycleTests.cs index 3d1d8898b..e824044f0 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcLifecycleTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcLifecycleTests.cs @@ -182,4 +182,42 @@ public void BuildStartEnvelope_Minimal_HasNoExperimental() Assert.Equal("iso:abc", root.GetProperty("sandboxId").GetString()); Assert.False(root.TryGetProperty("experimental", out _)); } + + [Fact] + public void BuildStartEnvelope_RelaysStableTelemetryAndCorrelation() + { + var options = new StartSandboxOptions + { + CorrelationVector = "AAAAAAAAAAAAAAAAAAAAAA.0", + TelemetryEnabled = true, + }; + var root = MxcLifecycle + .BuildStartEnvelope(new SandboxId("iso:abc"), options); + + Assert.Equal( + "AAAAAAAAAAAAAAAAAAAAAA.0", + root["correlationVector"]?.GetValue()); + Assert.True(root["telemetry"]?["enabled"]?.GetValue()); + Assert.Null(root["experimental"]); + } + + [Fact] + public void BuildExecEnvelope_RelaysStableTelemetryAndCorrelation() + { + var options = new StateAwareOperationOptions + { + CorrelationVector = "AAAAAAAAAAAAAAAAAAAAAA.0", + TelemetryEnabled = true, + }; + var root = MxcLifecycle.BuildExecEnvelope( + new SandboxId("iso:abc"), + "echo hi", + options); + + Assert.Equal( + "AAAAAAAAAAAAAAAAAAAAAA.0", + root["correlationVector"]?.GetValue()); + Assert.True(root["telemetry"]?["enabled"]?.GetValue()); + Assert.Null(root["experimental"]); + } } diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs index e724d2966..4af3e61fd 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs @@ -59,6 +59,7 @@ public static ProvisionResult ProvisionSandbox(ProvisionSandboxOptions? options { SandboxId = new SandboxId(sandboxId), MetadataJson = metadata?.ToJsonString(), + CorrelationVector = result["correlationVector"]?.GetValue(), }; } @@ -73,6 +74,7 @@ internal static JsonObject BuildProvisionEnvelope(ProvisionSandboxOptions? optio { envelope["filesystem"] = SerializeToNode(fs); } + ApplyTelemetry(envelope, options?.TelemetryEnabled); if (options?.User is { } user) { SetBackendConfig(envelope, "provision", "user", SerializeToNode(user)); @@ -93,6 +95,7 @@ internal static JsonObject BuildStartEnvelope(SandboxId id, StartSandboxOptions? { var envelope = NewEnvelope("start"); envelope["sandboxId"] = id.Value; + ApplyOperationOptions(envelope, options?.CorrelationVector, options?.TelemetryEnabled); if (options?.Size is { } size) { // The wire model reads the sizing profile from `configurationId` @@ -113,11 +116,14 @@ internal static JsonObject BuildStartEnvelope(SandboxId id, StartSandboxOptions? /// to release native resources. /// /// The exec could not be started. - public static MxcSandboxProcess ExecInSandbox(SandboxId id, string command) + public static MxcSandboxProcess ExecInSandbox( + SandboxId id, + string command, + StateAwareOperationOptions? options = null) { ArgumentNullException.ThrowIfNull(command); - var requestJson = BuildExecEnvelope(id, command).ToJsonString(); + var requestJson = BuildExecEnvelope(id, command, options).ToJsonString(); var requestBuf = ToNullTerminatedUtf8(requestJson); unsafe @@ -143,10 +149,14 @@ public static MxcSandboxProcess ExecInSandbox(SandboxId id, string command) // Build the exec request envelope: sandboxId + the command as the // cross-cutting process.commandLine. - internal static JsonObject BuildExecEnvelope(SandboxId id, string command) + internal static JsonObject BuildExecEnvelope( + SandboxId id, + string command, + StateAwareOperationOptions? options = null) { var envelope = NewEnvelope("exec"); envelope["sandboxId"] = id.Value; + ApplyOperationOptions(envelope, options?.CorrelationVector, options?.TelemetryEnabled); envelope["process"] = new JsonObject { ["commandLine"] = command }; return envelope; } @@ -159,12 +169,13 @@ internal static JsonObject BuildExecEnvelope(SandboxId id, string command) public static async Task ExecInSandboxAsync( SandboxId id, string command, + StateAwareOperationOptions? options = null, CancellationToken cancellationToken = default) { // Offload the blocking exec-start P/Invoke so this method never blocks // the caller's thread (for a backend that relays exec internally, the // whole exec runs during ExecInSandbox). - var proc = await Task.Run(() => ExecInSandbox(id, command), cancellationToken) + var proc = await Task.Run(() => ExecInSandbox(id, command, options), cancellationToken) .ConfigureAwait(false); try { @@ -186,21 +197,35 @@ public static async Task ExecInSandboxAsync( } } + /// + /// Compatibility overload retaining the original positional cancellation + /// token signature. + /// + public static Task ExecInSandboxAsync( + SandboxId id, + string command, + CancellationToken cancellationToken) => + ExecInSandboxAsync(id, command, null, cancellationToken); + /// Stop a running sandbox. /// Stopping failed. - public static void StopSandbox(SandboxId id) + public static void StopSandbox(SandboxId id, StateAwareOperationOptions? options = null) { var envelope = NewEnvelope("stop"); envelope["sandboxId"] = id.Value; + ApplyOperationOptions(envelope, options?.CorrelationVector, options?.TelemetryEnabled); RunEnvelopePhase(envelope); } /// Deprovision (destroy) a sandbox, releasing its resources. /// Deprovisioning failed. - public static void DeprovisionSandbox(SandboxId id) + public static void DeprovisionSandbox( + SandboxId id, + StateAwareOperationOptions? options = null) { var envelope = NewEnvelope("deprovision"); envelope["sandboxId"] = id.Value; + ApplyOperationOptions(envelope, options?.CorrelationVector, options?.TelemetryEnabled); RunEnvelopePhase(envelope); } @@ -212,6 +237,26 @@ public static void DeprovisionSandbox(SandboxId id) ["phase"] = phase, }; + private static void ApplyOperationOptions( + JsonObject envelope, + string? correlationVector, + bool? telemetryEnabled) + { + if (!string.IsNullOrEmpty(correlationVector)) + { + envelope["correlationVector"] = correlationVector; + } + ApplyTelemetry(envelope, telemetryEnabled); + } + + private static void ApplyTelemetry(JsonObject envelope, bool? telemetryEnabled) + { + if (telemetryEnabled is not null) + { + envelope["telemetry"] = new JsonObject { ["enabled"] = telemetryEnabled.Value }; + } + } + // Nest a backend-specific config value under experimental.isolation_session.. private static void SetBackendConfig(JsonObject envelope, string phase, string key, JsonNode? value) { diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs index b8fb1e819..fd5cfa542 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs @@ -31,6 +31,14 @@ public sealed class SandboxPolicy /// Execution timeout in milliseconds (null = no timeout). [JsonPropertyName("timeoutMs")] public uint? TimeoutMs { get; set; } + + /// + /// Stable per-invocation telemetry switch. Setting this to + /// is necessary but does not bypass persisted user consent or administrative + /// policy. Omitted or false keeps telemetry off. + /// + [JsonPropertyName("telemetryEnabled")] + public bool? TelemetryEnabled { get; set; } } /// Filesystem section of a . diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs index fa03380e2..de74a3031 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs @@ -29,6 +29,9 @@ public sealed class ProvisionSandboxOptions /// Optional Entra credentials for a cloud-agent sandbox. public SandboxUserCredentials? User { get; set; } + + /// Enable stable telemetry for this phase, subject to consent and policy. + public bool? TelemetryEnabled { get; set; } } /// Options for . @@ -44,6 +47,22 @@ public sealed class StartSandboxOptions /// Optional Entra credentials (must match those given at provision). public SandboxUserCredentials? User { get; set; } + + /// The correlation vector returned by provision. + public string? CorrelationVector { get; set; } + + /// Enable stable telemetry for this phase, subject to consent and policy. + public bool? TelemetryEnabled { get; set; } +} + +/// Options shared by state-aware exec, stop, and deprovision phases. +public sealed class StateAwareOperationOptions +{ + /// The correlation vector returned by provision. + public string? CorrelationVector { get; set; } + + /// Enable stable telemetry for this phase, subject to consent and policy. + public bool? TelemetryEnabled { get; set; } } /// The result of . @@ -57,4 +76,10 @@ public sealed class ProvisionResult /// user identity), or null when the backend produced none. /// public string? MetadataJson { get; init; } + + /// + /// Correlation vector to relay through later lifecycle phase options when + /// telemetry is enabled. + /// + public string? CorrelationVector { get; init; } } diff --git a/sdk/dotnet/README.md b/sdk/dotnet/README.md index 7e978d798..a6b856ffd 100644 --- a/sdk/dotnet/README.md +++ b/sdk/dotnet/README.md @@ -89,6 +89,13 @@ for the full design. `MxcTelemetry` is UI-agnostic: call it once at first run, and again at any later time from your own settings surface. +Collection is also disabled for each invocation unless the caller opts in. +Set `SandboxPolicy.TelemetryEnabled = true` for one-shot `Run`/`Spawn` calls. +For state-aware calls, set `TelemetryEnabled = true` in each phase's options +and relay `ProvisionResult.CorrelationVector` through the later phases' +`CorrelationVector` option. These switches do not bypass persisted consent or +administrative policy. + ```csharp using Microsoft.Mxc.Sdk; @@ -234,4 +241,3 @@ than linking third-party code directly against `mxc_ffi`. `scripts/check-dotnet-errorcode-parity.js` enforces that. - The C# package version tracks the Rust workspace version; `scripts/check-version-sync.js` enforces that. - diff --git a/sdk/node/README.md b/sdk/node/README.md index 4552d44c4..d9d5cc283 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -261,7 +261,7 @@ await stopSandbox(sandboxId, undefined, opts); await deprovisionSandbox(sandboxId, undefined, opts); ``` -> **Correlating telemetry across phases:** when experimental telemetry is enabled, `provisionSandbox` returns a `correlationVector` (a Microsoft Correlation Vector). Relay it verbatim as `options.correlationVector` on every later phase so all phases of one lifecycle share a telemetry base prefix (emitted under `__TlgCV__`). The client relays the value unchanged; the executor validates it on each phase and derives that phase's own vector from it (spinning a fresh child element off a mutable base, or reseeding if it is missing or malformed). It is `undefined` when telemetry is off, and safe to omit otherwise. +> **Correlating telemetry across phases:** when telemetry is enabled, `provisionSandbox` returns a `correlationVector` (a Microsoft Correlation Vector). Relay it verbatim as `options.correlationVector` on every later phase so all phases of one lifecycle share a telemetry base prefix (emitted under `__TlgCV__`). The client relays the value unchanged; the executor validates it on each phase and derives that phase's own vector from it (spinning a fresh child element off a mutable base, or reseeding if it is missing or malformed). It is `undefined` when telemetry is off, and safe to omit otherwise. `windows_sandbox` follows the same shape (substitute the containment string and provide `filesystem.readwritePaths` / `readonlyPaths` at provision if needed). See [`docs/windows-sandbox/windows-sandbox.md`](https://github.com/microsoft/mxc/blob/main/docs/windows-sandbox/windows-sandbox.md) for the per-phase config matrix. @@ -443,6 +443,12 @@ emission (never a Windows-level setting like Diagnostics & feedback). See [`docs/telemetry/telemetry-consent-design.md`](https://github.com/microsoft/mxc/blob/main/docs/telemetry/telemetry-consent-design.md) for the full design. +Telemetry is additionally off per invocation unless a one-shot +`ContainerConfig` includes `telemetry: { enabled: true }`, or a state-aware +call passes `options.telemetry: { enabled: true }`. This stable switch does not +require `options.experimental` and cannot bypass consent or administrative +policy. + The SDK does not ship a consent UI — call these once at first run, and again at any later time from your own settings surface: @@ -531,4 +537,3 @@ Like every other consent surface it fails closed: an unreadable or missing ## License [MIT](https://github.com/microsoft/mxc/blob/main/sdk/node/LICENSE.md). Contributions welcome — see the main [MXC repository](https://github.com/microsoft/mxc). - diff --git a/sdk/node/src/generated/wire.ts b/sdk/node/src/generated/wire.ts index 2dc088a9f..70f8e99ff 100644 --- a/sdk/node/src/generated/wire.ts +++ b/sdk/node/src/generated/wire.ts @@ -78,10 +78,6 @@ export interface Experimental { * Seatbelt backend config (pre-promotion alias). */ seatbelt?: Seatbelt | null; - /** - * Telemetry configuration. - */ - telemetry?: Telemetry | null; /** * Placeholder feature for testing experimental infrastructure. */ @@ -343,7 +339,7 @@ export interface Seatbelt { } /** - * Telemetry configuration (`experimental.telemetry`). + * Telemetry configuration (`telemetry`). */ export interface Telemetry { /** @@ -471,7 +467,7 @@ export interface MXCConfiguration { */ containment?: Containment | null; /** - * Microsoft Correlation Vector (MS-CV) seeded at `provision` and returned in the provision result. The client relays it verbatim into every later state-aware phase so all phases of one lifecycle share a telemetry base prefix (emitted under `__TlgCV__`). The executor is the trust boundary: on each non-provision phase it validates the relayed value and *spins* a fresh child element off a mutable base (so multiple invocations of one phase stay distinct), passes an already-frozen vector through unchanged, and reseeds a brand-new base if the relayed value is absent or malformed — so a missing or hostile relay never reaches telemetry unvalidated. Ignored unless experimental telemetry is enabled; not valid on one-shot requests. + * Microsoft Correlation Vector (MS-CV) seeded at `provision` and returned in the provision result. The client relays it verbatim into every later state-aware phase so all phases of one lifecycle share a telemetry base prefix (emitted under `__TlgCV__`). The executor is the trust boundary: on each non-provision phase it validates the relayed value and *spins* a fresh child element off a mutable base (so multiple invocations of one phase stay distinct), passes an already-frozen vector through unchanged, and reseeds a brand-new base if the relayed value is absent or malformed — so a missing or hostile relay never reaches telemetry unvalidated. Ignored unless telemetry is enabled; not valid on one-shot requests. */ correlationVector?: string | null; /** @@ -518,6 +514,10 @@ export interface MXCConfiguration { * macOS Seatbelt backend configuration. Used when containment is `seatbelt`. */ seatbelt?: Seatbelt | null; + /** + * Telemetry configuration. + */ + telemetry?: Telemetry | null; /** * Cross-platform UI isolation policy. */ diff --git a/sdk/node/src/sandbox.ts b/sdk/node/src/sandbox.ts index 350032fd7..e86632325 100644 --- a/sdk/node/src/sandbox.ts +++ b/sdk/node/src/sandbox.ts @@ -6,7 +6,13 @@ import * as os from 'os'; import { spawn, ChildProcess } from 'child_process'; import { randomBytes } from "crypto"; import { parse as semverParse } from 'semver'; -import { SandboxPolicy, ContainerConfig, ContainmentType, ContainmentBackend } from './types.js'; +import { + SandboxPolicy, + ContainerConfig, + ContainmentType, + ContainmentBackend, + TelemetryConfig, +} from './types.js'; import { prepareSpawn, diagLogVersion, applyLinuxNetworkPolicy } from './helper.js'; import { diagLog } from './diagnostic.js'; import { MxcError, mxcErrorFromEnvelope } from './errors.js'; @@ -400,6 +406,13 @@ export interface SandboxSpawnOptions { */ experimental?: boolean; + /** + * State-aware lifecycle only: stable per-invocation telemetry configuration. + * Telemetry remains off unless `enabled` is explicitly `true`, persisted user + * consent is granted, and administrative policy permits collection. + */ + telemetry?: TelemetryConfig; + /** * Allow testing-only, deliberately-permissive features that must never run * in production — currently `network.proxy.builtinTestServer` (a bundled diff --git a/sdk/node/src/state-aware-helper.ts b/sdk/node/src/state-aware-helper.ts index 0fc072981..f8e733879 100644 --- a/sdk/node/src/state-aware-helper.ts +++ b/sdk/node/src/state-aware-helper.ts @@ -7,6 +7,7 @@ import { SandboxSpawnOptions } from './sandbox.js'; import { mxcErrorFromCode, mxcErrorFromEnvelope, WireError } from './errors.js'; import { diagLog } from './diagnostic.js'; import { Phase, StateAwareContainmentBackend } from './state-aware-types.js'; +import { TelemetryConfig } from './types.js'; export const STATE_AWARE_VERSION = '0.6.0-alpha'; @@ -53,6 +54,7 @@ export interface BuildEnvelopeArgs { containment?: StateAwareContainmentBackend; // provision only sandboxId?: string; // non-provision only correlationVector?: string; // non-provision relay (from provision) + telemetry?: TelemetryConfig; // stable cross-cutting config config?: Record; } @@ -63,7 +65,15 @@ export interface BuildEnvelopeArgs { * remaining backend-specific fields under `experimental..`. */ export function buildStateAwareEnvelope(args: BuildEnvelopeArgs): Record { - const { phase, backendKey, containment, sandboxId, correlationVector, config } = args; + const { + phase, + backendKey, + containment, + sandboxId, + correlationVector, + telemetry, + config, + } = args; // Copy of config; fields are removed as they are lifted into the envelope. // Anything left becomes experimental... const backendSpecific: Record = { ...(config ?? {}) }; @@ -83,6 +93,9 @@ export function buildStateAwareEnvelope(args: BuildEnvelopeArgs): Record { metadata?: ProvisionMetadataFor; /** * Correlation vector (MS-CV) seeded by the executor for this lifecycle when - * experimental telemetry is enabled. Relay it verbatim as + * telemetry is enabled. Relay it verbatim as * {@link SandboxSpawnOptions.correlationVector} on every later phase so all * phases of the lifecycle share a telemetry base prefix. The client relays it * unchanged; the executor derives each phase's own vector from it (spinning a diff --git a/sdk/node/src/state-aware.ts b/sdk/node/src/state-aware.ts index 3c1cfa51a..e310ea8a1 100644 --- a/sdk/node/src/state-aware.ts +++ b/sdk/node/src/state-aware.ts @@ -65,6 +65,7 @@ export async function provisionSandbox( phase: 'provision', backendKey: containment, containment, + telemetry: options.telemetry, config: config as Record | undefined, }); const result = await nonExecCall<{ @@ -94,6 +95,7 @@ export async function startSandbox( backendKey, sandboxId, correlationVector: options.correlationVector, + telemetry: options.telemetry, config: config as Record | undefined, }); return nonExecCall>(envelope, options); @@ -117,6 +119,7 @@ export function execInSandbox( backendKey, sandboxId, correlationVector: options.correlationVector, + telemetry: options.telemetry, config: config as unknown as Record, }); const { executablePath, args } = resolveBinaryAndCommonArgs(JSON.stringify(envelope), options); @@ -158,6 +161,7 @@ export async function execInSandboxAsync backendKey, sandboxId, correlationVector: options.correlationVector, + telemetry: options.telemetry, config: config as unknown as Record, }); const { stdout, stderr, exitCode } = await spawnAndCollect(envelope, options); @@ -187,6 +191,7 @@ export async function stopSandbox( backendKey, sandboxId, correlationVector: options.correlationVector, + telemetry: options.telemetry, config: config as Record | undefined, }); return nonExecCall>(envelope, options); @@ -207,6 +212,7 @@ export async function deprovisionSandbox backendKey, sandboxId, correlationVector: options.correlationVector, + telemetry: options.telemetry, config: config as Record | undefined, }); return nonExecCall>(envelope, options); diff --git a/sdk/node/src/types.ts b/sdk/node/src/types.ts index de029318b..ba6d74ae1 100644 --- a/sdk/node/src/types.ts +++ b/sdk/node/src/types.ts @@ -281,7 +281,7 @@ export interface PortMapping { } /** - * Telemetry configuration for experimental TraceLogging ETW support. + * Telemetry configuration for TraceLogging ETW support. */ export interface TelemetryConfig { /** @@ -323,12 +323,12 @@ export interface ContainerConfig { filesystem?: FilesystemConfig; /** Network access configuration */ network?: NetworkConfig; + /** Telemetry configuration for TraceLogging ETW support */ + telemetry?: TelemetryConfig; /** Experimental features (only applied when --experimental flag is set) */ experimental?: { /** WSLC SDK configuration for Linux containers from Windows */ wslc?: WslcConfig; - /** Telemetry configuration for experimental TraceLogging ETW support */ - telemetry?: TelemetryConfig; }; /** macOS Seatbelt sandbox configuration (macOS only) */ seatbelt?: SeatbeltConfig; diff --git a/sdk/node/tests/unit/state-aware.test.ts b/sdk/node/tests/unit/state-aware.test.ts index 566c6be6e..46458b086 100644 --- a/sdk/node/tests/unit/state-aware.test.ts +++ b/sdk/node/tests/unit/state-aware.test.ts @@ -148,6 +148,17 @@ describe('buildStateAwareEnvelope', () => { assert.strictEqual(provision.correlationVector, undefined); }); + it('places stable telemetry at the envelope top level', () => { + const env = buildStateAwareEnvelope({ + phase: 'start', + backendKey: 'isolation_session', + sandboxId: 'iso:abc', + telemetry: { enabled: true }, + }); + assert.deepStrictEqual(env.telemetry, { enabled: true }); + assert.strictEqual(env.experimental, undefined); + }); + }); describe('parseNonExecResponse', () => { @@ -341,6 +352,15 @@ describe('startSandbox', { skip: platformSkip }, () => { await startSandbox(id, undefined, testOptions({ correlationVector: 'BASEbaseBASEbaseBASEba.7' })); assert.strictEqual(fake.captured.envelope?.correlationVector, 'BASEbaseBASEbaseBASEba.7'); }); + + it('relays stable telemetry from options onto the start envelope', async () => { + const fake = fakeSpawn({ stdout: '{"result":{}}', exitCode: 0 }); + _setSpawnImpl(fake.spawn); + const id = 'iso:reg-abc:prov-1' as SandboxId<'isolation_session'>; + await startSandbox(id, undefined, testOptions({ telemetry: { enabled: true } })); + assert.deepStrictEqual(fake.captured.envelope?.telemetry, { enabled: true }); + assert.strictEqual(fake.captured.envelope?.experimental, undefined); + }); }); describe('stopSandbox', { skip: platformSkip }, () => { diff --git a/sdk/node/tests/unit/wire-conformance.test.ts b/sdk/node/tests/unit/wire-conformance.test.ts index bafe4e91b..bc7f272de 100644 --- a/sdk/node/tests/unit/wire-conformance.test.ts +++ b/sdk/node/tests/unit/wire-conformance.test.ts @@ -56,6 +56,7 @@ import type { PortMapping as PublicPortMapping, LxcConfig, SeatbeltConfig, + TelemetryConfig, ContainerConfig, ClipboardPolicy as PublicClipboardPolicy, ContainmentType, @@ -74,6 +75,7 @@ import type { PortMapping as WirePortMapping, Lxc as WireLxc, Seatbelt as WireSeatbelt, + Telemetry as WireTelemetry, MXCConfiguration as WireMxcConfig, ClipboardPolicy as WireClipboardPolicy, Containment as WireContainment, @@ -135,6 +137,7 @@ type _BaseProcessUiVals = AssertTrue>; type _PortMappingVals = AssertTrue>; type _SeatbeltVals = AssertTrue>; +type _TelemetryVals = AssertTrue>; type _LxcVals = AssertTrue, WireLxc>>; // --- key conformance (rename / removal detection) ------------------------- @@ -155,6 +158,7 @@ type _BaseProcessUiKeys = AssertTrue, never>>; type _PortMappingKeys = AssertTrue, never>>; type _SeatbeltKeys = AssertTrue, never>>; +type _TelemetryKeys = AssertTrue, never>>; // `FilesystemConfig.clearPolicyOnExit` is an SDK-side convenience flag mapped // into `lifecycle.preservePolicy`; it is not a wire `filesystem` field. @@ -208,6 +212,7 @@ type _ProcessContainerWireKeys = AssertTrue< type _SeatbeltWireKeys = AssertTrue< Equivalent, 'guiAccess' | 'launchMethod'> >; +type _TelemetryWireKeys = AssertTrue, never>>; // Root: the SDK's `ContainerConfig` intentionally omits the schema-metadata keys // (`$schema`, `_comment`), the state-aware-only keys (`phase`, `sandboxId`, diff --git a/src/core/lxc/src/main.rs b/src/core/lxc/src/main.rs index 372049b4e..28fd9b914 100644 --- a/src/core/lxc/src/main.rs +++ b/src/core/lxc/src/main.rs @@ -266,17 +266,12 @@ fn main() { request.testing_features_enabled = cli.allow_testing_features; request.dry_run = cli.dry_run; - // ── Telemetry init (experimental) ─────────────────────────────── - let telemetry_active = if request.experimental_enabled { - request - .experimental - .telemetry - .as_ref() - .map(|c| telemetry::init(c, &mut logger)) - .unwrap_or(false) - } else { - false - }; + // ── Telemetry init ────────────────────────────────────────────── + let telemetry_active = request + .telemetry + .as_ref() + .map(|c| telemetry::init(c, &mut logger)) + .unwrap_or(false); // Install a crash-telemetry panic hook once telemetry is active, chaining // the previously-installed hook so the default stderr backtrace still @@ -319,7 +314,7 @@ fn main() { display_script_results(&response, &mut logger); - // ── Telemetry emit (experimental) ─────────────────────────────── + // ── Telemetry emit ────────────────────────────────────────────── telemetry::emit_completion( telemetry_active, &request.containment, diff --git a/src/core/mxc-sdk/README.md b/src/core/mxc-sdk/README.md index f1878c8b8..dcf972447 100644 --- a/src/core/mxc-sdk/README.md +++ b/src/core/mxc-sdk/README.md @@ -24,7 +24,7 @@ let policy = SandboxPolicy { capture_denials: None, }; let mut request = build_request(&policy, None)?; -request.set_script("echo hello"); +request.set_script("echo hello").set_telemetry_enabled(true); // Run to completion and capture the output. let output = run(request)?; @@ -45,6 +45,10 @@ through the shared parser. The returned [`SandboxRequest`] has an empty command line — set the command with [`SandboxRequest::set_script`] (and any working directory / env) before spawning. +Telemetry remains off unless `SandboxRequest::set_telemetry_enabled(true)` is +called. Enabling that per-invocation switch still requires persisted user +consent and a permitting administrative policy. + To target a specific backend instead of the host default, use [`build_request_with_containment`] with a [`Containment`] — the same choice the TypeScript SDK makes with `createConfigFromPolicy(policy, containment)`. @@ -316,6 +320,11 @@ The crate is UI-agnostic: it does not render a prompt. Call `needs_consent_prompt()` once at first sandbox run, show your own UI, then record the answer — and let a settings surface flip it at any later time. +Telemetry is also off per invocation unless the request explicitly enables it +with `SandboxRequest::set_telemetry_enabled(true)`. This stable switch does not +require `set_experimental(true)` and cannot bypass consent or administrative +policy. + ```rust,no_run use mxc_sdk::telemetry; diff --git a/src/core/mxc_darwin/src/main.rs b/src/core/mxc_darwin/src/main.rs index 8cb8d96b4..4a12f479f 100644 --- a/src/core/mxc_darwin/src/main.rs +++ b/src/core/mxc_darwin/src/main.rs @@ -181,21 +181,16 @@ fn main() { fn run_seatbelt(request: &ExecutionRequest, logger: &mut Logger) -> ! { use wxc_common::telemetry; - // ── Telemetry init (experimental) ─────────────────────────────── + // ── Telemetry init ────────────────────────────────────────────── // Mirrors lxc-exec / wxc-exec. The ETW provider has no macOS backend today, // so `init` returns false and every emit below is a no-op; wiring it anyway // keeps the three executors structurally identical and ready the moment // telemetry gains a macOS sink. - let telemetry_active = if request.experimental_enabled { - request - .experimental - .telemetry - .as_ref() - .map(|c| telemetry::init(c, logger)) - .unwrap_or(false) - } else { - false - }; + let telemetry_active = request + .telemetry + .as_ref() + .map(|c| telemetry::init(c, logger)) + .unwrap_or(false); // Install a crash-telemetry panic hook once telemetry is active, chaining // the previously-installed hook so the default stderr backtrace still @@ -226,7 +221,7 @@ fn run_seatbelt(request: &ExecutionRequest, logger: &mut Logger) -> ! { display_script_results(&response, logger); - // ── Telemetry emit (experimental) ─────────────────────────────── + // ── Telemetry emit ────────────────────────────────────────────── telemetry::emit_completion( telemetry_active, &request.containment, diff --git a/src/core/mxc_engine/src/lib.rs b/src/core/mxc_engine/src/lib.rs index 552b51cc4..ba92336ed 100644 --- a/src/core/mxc_engine/src/lib.rs +++ b/src/core/mxc_engine/src/lib.rs @@ -53,7 +53,9 @@ pub use run::{resolve_runner, run, ResolvedRunner}; pub use state_aware::{exec_state_aware_json, run_state_aware, run_state_aware_json}; use wxc_common::logger::{Logger, Mode}; +use wxc_common::models::{ContainmentBackend, FailurePhase, ScriptResponse}; use wxc_common::sandbox_process::{SandboxProcess, StreamCloser}; +use wxc_common::telemetry; /// Spawn a streaming [`SandboxProcess`] handle for a [`SandboxRequest`] built /// by [`build_request`] (with the command, and any working directory / env, @@ -65,20 +67,207 @@ use wxc_common::sandbox_process::{SandboxProcess, StreamCloser}; /// [`ErrorCode::UnsupportedContainment`]. pub fn spawn(request: &SandboxRequest) -> Result, Error> { let mut logger = Logger::new(Mode::Buffer); - let process = dispatch::spawn_runner(&request.inner, &mut logger).map_err(Error::from)?; + let telemetry_active = request + .inner + .telemetry + .as_ref() + .map(|config| telemetry::init(config, &mut logger)) + .unwrap_or(false); + let containment = request.inner.containment.clone(); + let started = std::time::Instant::now(); + let process = match dispatch::spawn_runner(&request.inner, &mut logger) { + Ok(process) => process, + Err(error) => { + telemetry::emit_sdk_early_exit( + telemetry_active, + &containment, + telemetry::FailureReason::InitError, + ); + return Err(Error::from(error)); + } + }; let mut warnings = process.warnings().to_vec(); for warning in logger.take_warnings() { if !warnings.contains(&warning) { warnings.push(warning); } } - if warnings.is_empty() { - Ok(process) + let process: Box = if warnings.is_empty() { + process } else { - Ok(Box::new(ProcessWithWarnings { + Box::new(ProcessWithWarnings { inner: process, warnings, + }) + }; + if telemetry_active { + Ok(Box::new(TelemetryProcess { + inner: process, + active: true, + mode: TelemetryMode::OneShot(containment), + started, })) + } else { + Ok(process) + } +} + +/// Streaming process wrapper that owns one telemetry provider reference and +/// emits exactly one terminal event for this SDK invocation. +struct TelemetryProcess { + inner: Box, + active: bool, + mode: TelemetryMode, + started: std::time::Instant, +} + +enum TelemetryMode { + OneShot(ContainmentBackend), + StateAware { + backend: String, + phase: String, + correlation_vector: String, + }, +} + +pub(crate) fn wrap_state_aware_telemetry_process( + process: Box, + active: bool, + backend: String, + phase: String, + correlation_vector: String, + started: std::time::Instant, +) -> Box { + if active { + Box::new(TelemetryProcess { + inner: process, + active: true, + mode: TelemetryMode::StateAware { + backend, + phase, + correlation_vector, + }, + started, + }) + } else { + process + } +} + +impl TelemetryProcess { + fn emit(&mut self, result: &std::io::Result) { + if !self.active { + return; + } + let response = match result { + Ok(exit_code) => ScriptResponse { + exit_code: *exit_code, + ..Default::default() + }, + Err(error) if error.kind() == std::io::ErrorKind::TimedOut => ScriptResponse { + error_message: "sandbox execution timed out".to_string(), + failure_phase: FailurePhase::Timeout, + ..Default::default() + }, + Err(error) => ScriptResponse { + error_message: error.to_string(), + failure_phase: FailurePhase::PostLaunchFailed, + ..Default::default() + }, + }; + match &self.mode { + TelemetryMode::OneShot(containment) => { + telemetry::emit_sdk_completion(true, containment, &response, self.started.elapsed()) + } + TelemetryMode::StateAware { + backend, + phase, + correlation_vector, + } => { + let outcome = match result { + Ok(exit_code) => Ok( + wxc_common::state_aware_dispatch::DispatchOutcome::ExecCompleted { + exit_code: *exit_code, + }, + ), + Err(error) => Err(wxc_common::mxc_error::MxcError::backend_error( + error.to_string(), + )), + }; + telemetry::emit_sdk_state_aware( + true, + telemetry::TelemetryContext { + backend, + phase, + correlation_vector, + }, + &outcome, + self.started.elapsed(), + ); + } + } + self.active = false; + } +} + +impl Drop for TelemetryProcess { + fn drop(&mut self) { + if self.active { + telemetry::shutdown(); + self.active = false; + } + } +} + +impl SandboxProcess for TelemetryProcess { + fn warnings(&self) -> &[String] { + self.inner.warnings() + } + + fn output_metadata(&self) -> Option<&wxc_common::models::SandboxOutputMetadata> { + self.inner.output_metadata() + } + + fn take_stdin(&mut self) -> Option> { + self.inner.take_stdin() + } + + fn take_stdout(&mut self) -> Option> { + self.inner.take_stdout() + } + + fn take_stderr(&mut self) -> Option> { + self.inner.take_stderr() + } + + fn try_wait(&mut self) -> std::io::Result> { + let result = self.inner.try_wait(); + if let Ok(Some(exit_code)) = result { + self.emit(&Ok(exit_code)); + } + result + } + + fn id(&self) -> u32 { + self.inner.id() + } + + fn kill(&mut self) -> std::io::Result<()> { + self.inner.kill() + } + + fn wait(&mut self) -> std::io::Result { + let result = self.inner.wait(); + self.emit(&result); + result + } + + fn stdout_closer(&self) -> Option> { + self.inner.stdout_closer() + } + + fn stderr_closer(&self) -> Option> { + self.inner.stderr_closer() } } diff --git a/src/core/mxc_engine/src/policy.rs b/src/core/mxc_engine/src/policy.rs index 0b1cb9a97..89f6e0968 100644 --- a/src/core/mxc_engine/src/policy.rs +++ b/src/core/mxc_engine/src/policy.rs @@ -17,7 +17,7 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; use wxc_common::logger::{Logger, Mode}; -use wxc_common::models::ExecutionRequest; +use wxc_common::models::{ExecutionRequest, TelemetryConfig}; use wxc_common::mxc_error::MxcError; // --------------------------------------------------------------------------- @@ -778,6 +778,18 @@ impl SandboxRequest { self.inner.experimental_enabled = enabled; self } + + /// Enable or disable telemetry for this invocation. + /// + /// Enabling this per-request switch is necessary but not sufficient: + /// telemetry still requires persisted user consent and an administrative + /// policy that permits collection. It is independent of experimental mode. + pub fn set_telemetry_enabled(&mut self, enabled: bool) -> &mut Self { + self.inner.telemetry = Some(TelemetryConfig { + enabled: Some(enabled), + }); + self + } } /// Build a [`SandboxRequest`] from a [`SandboxPolicy`], resolving the host's @@ -874,7 +886,6 @@ fn build_wire_config( "deniedPaths": fs.denied_paths, }, }); - // `ui` is emitted only when the caller actually supplied one. // // The parser records presence as `ContainerPolicy::ui_specified`, and diff --git a/src/core/mxc_engine/src/state_aware.rs b/src/core/mxc_engine/src/state_aware.rs index 1c7f33fd6..ecb2657d9 100644 --- a/src/core/mxc_engine/src/state_aware.rs +++ b/src/core/mxc_engine/src/state_aware.rs @@ -22,8 +22,41 @@ use wxc_common::state_aware_dispatch::{ resolve_backend, run_state_aware as run_state_aware_fallback, DispatchOutcome, }; use wxc_common::state_aware_request::{MxcRequest, ParsedStateAwareRequest, Phase}; +use wxc_common::telemetry; use crate::error::Error; +use crate::wrap_state_aware_telemetry_process; + +fn phase_correlation(active: bool, phase: Phase, incoming: Option<&str>) -> String { + if !active { + return String::new(); + } + if phase != Phase::Provision { + if let Some(value) = + incoming.filter(|value| telemetry::correlation_vector::is_relayable(value)) + { + return telemetry::correlation_vector::spin(value); + } + } + telemetry::correlation_vector::seed() +} + +fn inject_correlation_vector( + outcome: &mut Result, + correlation_vector: &str, +) { + if let Ok(DispatchOutcome::Envelope(value)) = outcome { + if let Some(result) = value + .get_mut("result") + .and_then(|result| result.as_object_mut()) + { + result.insert( + "correlationVector".to_string(), + serde_json::Value::String(correlation_vector.to_string()), + ); + } + } +} /// Resolve `parsed`'s backend and run the requested state-aware phase. /// @@ -132,7 +165,37 @@ pub fn run_state_aware_json(request_json: &str, dry_run: bool) -> Result serde_json::to_string(&value).map_err(|e| { Error::from(MxcError::backend_error(format!( "serialising the response envelope failed: {e}" @@ -155,7 +218,44 @@ pub fn exec_state_aware_json(request_json: &str) -> Result Ok(wrap_state_aware_telemetry_process( + process, + telemetry_active, + backend, + phase.as_str().to_string(), + correlation, + started, + )), + Err(error) => { + let outcome = Err(error.clone()); + telemetry::emit_sdk_state_aware( + telemetry_active, + telemetry::TelemetryContext { + backend: &backend, + phase: phase.as_str(), + correlation_vector: &correlation, + }, + &outcome, + started.elapsed(), + ); + Err(Error::from(error)) + } + } } #[cfg(test)] @@ -165,6 +265,42 @@ mod tests { use wxc_common::mxc_error::MxcErrorCode; use wxc_common::state_aware_request::Phase; + #[test] + fn inactive_telemetry_does_not_create_a_correlation_vector() { + assert_eq!( + phase_correlation(false, Phase::Provision, None), + String::new() + ); + } + + #[test] + fn later_phase_spins_a_relayable_correlation_vector() { + let incoming = telemetry::correlation_vector::seed(); + let correlation = phase_correlation(true, Phase::Start, Some(&incoming)); + + assert_ne!(correlation, incoming); + assert!(correlation.starts_with(incoming.split('.').next().unwrap_or_default())); + assert!(telemetry::correlation_vector::is_relayable(&correlation)); + } + + #[test] + fn provision_result_receives_the_correlation_vector() { + let mut outcome = Ok(DispatchOutcome::Envelope(serde_json::json!({ + "result": { "sandboxId": "iso:abc" } + }))); + + inject_correlation_vector(&mut outcome, "AAAAAAAAAAAAAAAAAAAAAA.0"); + + let value = match outcome { + Ok(DispatchOutcome::Envelope(value)) => value, + other => panic!("unexpected outcome: {other:?}"), + }; + assert_eq!( + value["result"]["correlationVector"], + "AAAAAAAAAAAAAAAAAAAAAA.0" + ); + } + #[test] fn experimental_backend_requires_flag() { let parsed = ParsedStateAwareRequest { diff --git a/src/core/wxc/src/main.rs b/src/core/wxc/src/main.rs index a43070950..6f684ed74 100644 --- a/src/core/wxc/src/main.rs +++ b/src/core/wxc/src/main.rs @@ -403,19 +403,10 @@ fn log_state_aware_dispatch_error(logger: &mut Logger, error: &MxcError) { /// envelope to stdout and exits 1. Diagnostic logger output goes to stderr /// regardless of mode (per design §7.3 stream protocol — stdout reserved /// for the response envelope). -fn run_state_aware_main( - parsed: ParsedStateAwareRequest, - dry_run: bool, - experimental: bool, - logger: &mut Logger, -) -> ! { +fn run_state_aware_main(parsed: ParsedStateAwareRequest, dry_run: bool, logger: &mut Logger) -> ! { // Resolve attribution (phase + backend) and telemetry enablement BEFORE - // dispatch consumes `parsed`. State-aware telemetry is gated on - // `--experimental` exactly like the one-shot path, and reads the same typed - // `experimental.telemetry` field — the state-aware parser populates it while - // keeping the per-backend `experimental_raw` block for dispatch. A malformed - // telemetry block is rejected at parse time (as a state-aware envelope), so - // no client-error handling is needed here. + // dispatch consumes `parsed`. Telemetry is stable and independent of the + // `--experimental` gate used by experimental containment backends. let phase = parsed.phase.as_str(); // Whether this invocation is the provision phase. Provision seeds a fresh // random correlation-vector base and returns it in the result envelope; @@ -436,17 +427,12 @@ fn run_state_aware_main( .as_ref() .map(|b| b.wire_name()) .unwrap_or("unknown"); - let telemetry_active = if experimental { - parsed - .request - .experimental - .telemetry - .as_ref() - .map(|c| telemetry::init(c, logger)) - .unwrap_or(false) - } else { - false - }; + let telemetry_active = parsed + .request + .telemetry + .as_ref() + .map(|c| telemetry::init(c, logger)) + .unwrap_or(false); // Compute this phase's Microsoft Correlation Vector (MS-CV), executing the // pure seed-vs-spin plan against the operators. Only computed when telemetry @@ -1096,7 +1082,7 @@ fn main() { // `--experimental` on the CLI. parsed.request.experimental_enabled = cli.experimental; parsed.request.dry_run = cli.dry_run; - run_state_aware_main(parsed, cli.dry_run, cli.experimental, &mut logger) + run_state_aware_main(parsed, cli.dry_run, &mut logger) } Err(ParseError::OneShot(_)) | Err(ParseError::Decode(_)) => { eprint!("Request error\n{}", logger.get_buffer()); @@ -1114,17 +1100,12 @@ fn main() { request.testing_features_enabled = cli.allow_testing_features; request.dry_run = cli.dry_run; - // ── Telemetry init (experimental) ─────────────────────────────── - let telemetry_active = if request.experimental_enabled { - request - .experimental - .telemetry - .as_ref() - .map(|c| telemetry::init(c, &mut logger)) - .unwrap_or(false) - } else { - false - }; + // ── Telemetry init ────────────────────────────────────────────── + let telemetry_active = request + .telemetry + .as_ref() + .map(|c| telemetry::init(c, &mut logger)) + .unwrap_or(false); // Install a crash-telemetry panic hook once telemetry is active, chaining // the previously-installed hook so the default stderr backtrace still @@ -1448,12 +1429,18 @@ fn main() { } if cli.dry_run { + telemetry::emit_completion( + telemetry_active, + &request.containment, + &response, + run_elapsed, + ); handle_dry_run_exit(&response, &mut logger); } display_script_results(&response, &mut logger); - // ── Telemetry emit (experimental) ─────────────────────────────── + // ── Telemetry emit ────────────────────────────────────────────── telemetry::emit_completion( telemetry_active, &request.containment, diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index 2dd544961..a7d279a90 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -73,6 +73,25 @@ where <&RawValue>::deserialize(deserializer).map(Some) } +fn reject_legacy_telemetry_raw(experimental: Option<&RawValue>) -> Result<(), WxcError> { + let Some(experimental) = experimental else { + return Ok(()); + }; + let value: serde_json::Value = serde_json::from_str(experimental.get()) + .map_err(|error| WxcError::ConfigParse(error.to_string()))?; + if value + .as_object() + .is_some_and(|object| object.contains_key("telemetry")) + { + return Err(WxcError::ConfigParse( + "'experimental.telemetry' has moved to the stable section; \ + use top-level 'telemetry' instead." + .to_string(), + )); + } + Ok(()) +} + // ---------- Public API ---------- /// Options for [`load_mxc_request_with_options`]. @@ -121,6 +140,9 @@ pub fn load_request_with_options( ) -> Result { let result = (|| { let json_str = decode_request_input_without_logging(input, opts.is_base64)?; + let discriminator: RequestDiscriminator<'_> = config_deserialize::from_str(&json_str) + .map_err(|error| WxcError::ConfigParse(error.to_string()))?; + reject_legacy_telemetry_raw(discriminator.experimental)?; let cfg: wire::MxcConfig = config_deserialize::from_str(&json_str) .map_err(|error| WxcError::ConfigParse(error.to_string()))?; @@ -144,6 +166,17 @@ pub fn load_request_from_value( allow_missing_command: bool, ) -> Result { let result = (|| { + if config + .get("experimental") + .and_then(serde_json::Value::as_object) + .is_some_and(|object| object.contains_key("telemetry")) + { + return Err(WxcError::ConfigParse( + "'experimental.telemetry' has moved to the stable section; \ + use top-level 'telemetry' instead." + .to_string(), + )); + } let cfg: wire::MxcConfig = config_deserialize::from_value(config) .map_err(|error| WxcError::ConfigParse(error.to_string()))?; @@ -229,6 +262,7 @@ fn parse_mxc_request_json( .map(MxcRequest::StateAware) .map_err(|e| ParseError::StateAware(MxcError::malformed_request(e.to_string()))) } else { + reject_legacy_telemetry_raw(discriminator.experimental).map_err(ParseError::OneShot)?; let cfg: wire::MxcConfig = config_deserialize::from_str(json_str) .map_err(|error| ParseError::OneShot(WxcError::ConfigParse(error.to_string())))?; convert_wire_config(cfg, logger, true, allow_missing_command) @@ -1279,14 +1313,10 @@ fn convert_wire_config( .to_string(); return Err(WxcError::ConfigParse(msg)); } - let telemetry = raw_exp.telemetry.map(|raw_t| TelemetryConfig { - enabled: raw_t.enabled, - }); ExperimentalConfig { test, windows_sandbox, wslc, - telemetry, } } else { ExperimentalConfig::default() @@ -1295,6 +1325,9 @@ fn convert_wire_config( // Top-level `seatbelt` config. Configs using `experimental.seatbelt` are // rejected above. let seatbelt = cfg.seatbelt.map(make_seatbelt_config); + let telemetry = cfg.telemetry.map(|raw| TelemetryConfig { + enabled: raw.enabled, + }); // UI section. Capture presence before the typed mapping consumes `ui`: // `UiPolicy::default()` is full lockdown, so an explicit lockdown `ui` is @@ -1323,6 +1356,7 @@ fn convert_wire_config( policy, lxc_config, seatbelt, + telemetry, experimental_enabled: false, testing_features_enabled: false, experimental, @@ -1395,6 +1429,13 @@ fn convert_wire_state_aware( return Err(WxcError::ConfigParse(msg)); } } + if exp.contains_key("telemetry") { + return Err(WxcError::ConfigParse( + "'experimental.telemetry' has moved to the stable section; \ + use top-level 'telemetry' instead." + .to_string(), + )); + } } validate_experimental_backend_keys(containment.as_ref(), experimental_raw.as_ref())?; @@ -1442,31 +1483,7 @@ fn convert_wire_state_aware( cfg.lifecycle = None; let require_process = phase == Phase::Exec; - let mut request = convert_wire_config(cfg, logger, require_process, allow_missing_command)?; - - // Populate the typed `experimental.telemetry` field from the raw block that - // was peeled off above. The rest of `experimental` is typed per-backend at - // dispatch time (from `experimental_raw`), but telemetry is a cross-cutting, - // backend-independent setting consumed the same way as the one-shot path — - // so it belongs on the typed request, not in a parallel raw-JSON reader. A - // present-but-malformed `telemetry` object is a client error (rejected here, - // exactly like the one-shot parser), not a silent disable. - if let Some(telemetry_val) = experimental_raw - .as_ref() - .and_then(|exp| exp.get("telemetry")) - { - let telemetry: TelemetryConfig = - serde_json::from_value(telemetry_val.clone()).map_err(|e| { - // Do not log here: state-aware parse errors are routed centrally - // and exactly once by the outer `load_mxc_request*` wrapper via - // `log_error(..., ErrorOutput::DiagnosticOnly)`. Logging here as - // well would produce a duplicate auxiliary diagnostic. - // Returning the error keeps stdout clean (envelope-owned) and - // yields a single auxiliary-sink line. - WxcError::ConfigParse(format!("invalid experimental.telemetry: {e}")) - })?; - request.experimental.telemetry = Some(telemetry); - } + let request = convert_wire_config(cfg, logger, require_process, allow_missing_command)?; Ok(ParsedStateAwareRequest { request, @@ -1701,22 +1718,17 @@ mod tests { #[test] fn state_aware_telemetry_populates_typed_field() { - // Telemetry is a cross-cutting setting: the state-aware parser must - // populate the typed `experimental.telemetry` field (consumed the same - // way as one-shot) while leaving the per-backend `experimental_raw` - // block intact for dispatch. + // Telemetry is a stable cross-cutting setting parsed identically for + // one-shot and state-aware requests. let json = r#"{ "phase": "provision", "containment": "isolation_session", - "experimental": {"telemetry": {"enabled": true}} + "telemetry": {"enabled": true}, + "experimental": {"isolation_session": {"provision": {}}} }"#; match load_mxc(json).unwrap() { MxcRequest::StateAware(p) => { - let telem = p - .request - .experimental - .telemetry - .expect("telemetry should be populated"); + let telem = p.request.telemetry.expect("telemetry should be populated"); assert_eq!(telem.enabled, Some(true)); // The raw block is still available for per-backend dispatch. assert!(p.experimental_raw.is_some()); @@ -1733,7 +1745,7 @@ mod tests { "experimental": {"isolation_session": {"start": {"opaqueFutureField": true}}} }"#; match load_mxc(json).unwrap() { - MxcRequest::StateAware(p) => assert!(p.request.experimental.telemetry.is_none()), + MxcRequest::StateAware(p) => assert!(p.request.telemetry.is_none()), MxcRequest::OneShot(_) => panic!("expected state-aware"), } } @@ -1745,7 +1757,7 @@ mod tests { let json = r#"{ "phase": "provision", "containment": "isolation_session", - "experimental": {"telemetry": 42} + "telemetry": 42 }"#; let r = load_mxc(json); assert!(matches!(r, Err(ParseError::StateAware(_))), "got {:?}", r); @@ -1760,7 +1772,7 @@ mod tests { let json = r#"{ "phase": "provision", "containment": "isolation_session", - "experimental": {"telemetry": 42} + "telemetry": 42 }"#; let encoded = base64_encode(json.as_bytes()); @@ -1783,12 +1795,30 @@ mod tests { let logged = std::fs::read_to_string(&log_path).unwrap(); assert_eq!( - logged.matches("invalid experimental.telemetry").count(), + logged.matches("telemetry").count(), 1, "expected exactly one auxiliary diagnostic, got: {logged:?}" ); } + #[test] + fn state_aware_experimental_telemetry_reports_migration() { + let json = r#"{ + "phase": "provision", + "containment": "isolation_session", + "experimental": {"telemetry": {"enabled": true}} + }"#; + let error = load_mxc(json).unwrap_err(); + let message = match &error { + ParseError::StateAware(error) => error.message.as_str(), + _ => panic!("expected state-aware error, got {error:?}"), + }; + assert!( + message.contains("'experimental.telemetry' has moved to the stable section"), + "got {error:?}" + ); + } + #[test] fn state_aware_non_object_experimental_is_rejected() { // A non-object `experimental` (here a bare number) is a hard parse error @@ -1815,7 +1845,7 @@ mod tests { "experimental": null }"#; match load_mxc(json).unwrap() { - MxcRequest::StateAware(p) => assert!(p.request.experimental.telemetry.is_none()), + MxcRequest::StateAware(p) => assert!(p.request.telemetry.is_none()), MxcRequest::OneShot(_) => panic!("expected state-aware"), } } @@ -5562,36 +5592,70 @@ mod tests { let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); let req = load_request(&encoded, &mut logger, true).unwrap(); - assert!(req.experimental.telemetry.is_none()); + assert!(req.telemetry.is_none()); } #[test] fn telemetry_enabled_true() { - let json = r#"{"process":{"commandLine":"echo hi"},"experimental":{"telemetry":{"enabled":true}}}"#; + let json = r#"{"process":{"commandLine":"echo hi"},"telemetry":{"enabled":true}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); let req = load_request(&encoded, &mut logger, true).unwrap(); - let telem = req.experimental.telemetry.expect("telemetry should be set"); + let telem = req.telemetry.expect("telemetry should be set"); assert_eq!(telem.enabled, Some(true)); } #[test] fn telemetry_enabled_false() { - let json = r#"{"process":{"commandLine":"echo hi"},"experimental":{"telemetry":{"enabled":false}}}"#; + let json = r#"{"process":{"commandLine":"echo hi"},"telemetry":{"enabled":false}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); let req = load_request(&encoded, &mut logger, true).unwrap(); - let telem = req.experimental.telemetry.expect("telemetry should be set"); + let telem = req.telemetry.expect("telemetry should be set"); assert_eq!(telem.enabled, Some(false)); } #[test] fn telemetry_empty_object() { - let json = r#"{"process":{"commandLine":"echo hi"},"experimental":{"telemetry":{}}}"#; + let json = r#"{"process":{"commandLine":"echo hi"},"telemetry":{}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); let req = load_request(&encoded, &mut logger, true).unwrap(); - let telem = req.experimental.telemetry.expect("telemetry should be set"); + let telem = req.telemetry.expect("telemetry should be set"); assert_eq!(telem.enabled, None); } + + #[test] + fn experimental_telemetry_reports_migration() { + let json = r#"{ + "process":{"commandLine":"echo hi"}, + "experimental":{"telemetry":{"enabled":true}} + }"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + let error = load_request(&encoded, &mut logger, true).unwrap_err(); + assert!( + error + .to_string() + .contains("'experimental.telemetry' has moved to the stable section"), + "got {error:?}" + ); + } + + #[test] + fn null_experimental_telemetry_reports_migration() { + let json = r#"{ + "process":{"commandLine":"echo hi"}, + "experimental":{"telemetry":null} + }"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + let error = load_request(&encoded, &mut logger, true).unwrap_err(); + assert!( + error + .to_string() + .contains("'experimental.telemetry' has moved to the stable section"), + "got {error:?}" + ); + } } diff --git a/src/core/wxc_common/src/models.rs b/src/core/wxc_common/src/models.rs index 779452882..f3a4bbb55 100644 --- a/src/core/wxc_common/src/models.rs +++ b/src/core/wxc_common/src/models.rs @@ -680,11 +680,9 @@ pub struct ExperimentalConfig { pub windows_sandbox: Option, /// WSL Container (WSLC SDK) backend (experimental). pub wslc: Option, - /// Telemetry configuration (experimental). - pub telemetry: Option, } -/// Telemetry configuration parsed from the JSON config `experimental.telemetry` section. +/// Telemetry configuration parsed from the top-level JSON config `telemetry` section. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default)] pub struct TelemetryConfig { @@ -716,6 +714,8 @@ pub struct ExecutionRequest { pub lxc_config: LxcConfig, /// Seatbelt (macOS) backend configuration (used when containment == Seatbelt). pub seatbelt: Option, + /// Per-invocation telemetry configuration. + pub telemetry: Option, /// Whether the --experimental flag was passed. pub experimental_enabled: bool, /// Whether the --allow-testing-features flag was passed. Gates testing-only, diff --git a/src/core/wxc_common/src/telemetry/mod.rs b/src/core/wxc_common/src/telemetry/mod.rs index 97e909ce2..0c7cf2c42 100644 --- a/src/core/wxc_common/src/telemetry/mod.rs +++ b/src/core/wxc_common/src/telemetry/mod.rs @@ -155,7 +155,7 @@ pub fn version() -> &'static str { /// Policy, and that denial overrides an existing user grant. It is never a /// grant in the other direction — no policy value can enable telemetry for /// a user who has not consented. -/// 3. `experimental.telemetry.enabled` in the JSON config is an explicit +/// 3. Top-level `telemetry.enabled` in the JSON config is an explicit /// per-invocation opt-in that can only ever subtract further: telemetry /// requires `Some(true)`, an explicit `Some(false)` always forces it off /// (useful for CI, support repros, or policy), and omitting the field @@ -166,12 +166,9 @@ pub fn version() -> &'static str { /// owns consent end-to-end (persistence, and the CLI/SDK toggle surfaces); it /// does not merely trust a per-request flag from the caller. /// -/// This function is *necessary but not sufficient*: telemetry is still an -/// experimental feature, so the executors only reach [`init`] at all when -/// `--experimental` was passed **and** the request carries an -/// `experimental.telemetry` block. A consenting user who omits that block — or -/// who supplies the block without `enabled: true` — gets no telemetry; the -/// gates compose, and every one of them can only subtract. +/// A consenting user who omits the top-level `telemetry` block — or who supplies +/// it without `enabled: true` — gets no telemetry; the gates compose, and every +/// one of them can only subtract. pub fn is_enabled(config: &TelemetryConfig) -> bool { // Fail closed: only an explicit `true` opts in. `None` is not "no // opinion" — an author who omits the field gets no telemetry, which is @@ -247,7 +244,15 @@ pub fn emit_completion( if already_emitted() { return; } + emit_completion_event(containment, response, elapsed); + shutdown(); +} +fn emit_completion_event( + containment: &ContainmentBackend, + response: &ScriptResponse, + elapsed: Duration, +) { let backend = containment.wire_name(); let failed = response.exit_code != 0; let outcome = if failed { "failure" } else { "success" }; @@ -280,7 +285,23 @@ pub fn emit_completion( response.exit_code, ); } +} +/// Emit completion telemetry for an in-process SDK invocation. +/// +/// Unlike [`emit_completion`], this does not use the executable-wide +/// exactly-once slot: SDK processes may run multiple or concurrent sandboxes, +/// and their handle wrappers enforce exactly-once emission per invocation. +pub fn emit_sdk_completion( + active: bool, + containment: &ContainmentBackend, + response: &ScriptResponse, + elapsed: Duration, +) { + if !active { + return; + } + emit_completion_event(containment, response, elapsed); shutdown(); } @@ -301,7 +322,11 @@ pub fn emit_early_exit(active: bool, containment: &ContainmentBackend, reason: F if already_emitted() { return; } + emit_early_exit_event(containment, reason); + shutdown(); +} +fn emit_early_exit_event(containment: &ContainmentBackend, reason: FailureReason) { let backend = containment.wire_name(); log_execution(&ExecutionEvent { @@ -325,7 +350,14 @@ pub fn emit_early_exit(active: bool, containment: &ContainmentBackend, reason: F reason, 1, ); +} +/// Emit an SDK spawn failure without claiming the executable-wide terminal slot. +pub fn emit_sdk_early_exit(active: bool, containment: &ContainmentBackend, reason: FailureReason) { + if !active { + return; + } + emit_early_exit_event(containment, reason); shutdown(); } @@ -670,7 +702,15 @@ pub fn emit_state_aware( if already_emitted() { return; } + emit_state_aware_event(ctx, outcome, elapsed); + shutdown(); +} +fn emit_state_aware_event( + ctx: TelemetryContext<'_>, + outcome: &Result, + elapsed: Duration, +) { let duration_ms = elapsed.as_millis() as u64; let plan = plan_state_aware(ctx, outcome, duration_ms); @@ -678,7 +718,22 @@ pub fn emit_state_aware( if let Some(reason) = plan.error { log_error(ctx, reason, plan.execution.exit_code); } +} +/// Emit telemetry for an in-process SDK state-aware invocation. +/// +/// The SDK wrapper owns exactly-once emission for its request/handle, so this +/// bypasses the executable-wide terminal slot used by `wxc-exec`. +pub fn emit_sdk_state_aware( + active: bool, + ctx: TelemetryContext<'_>, + outcome: &Result, + elapsed: Duration, +) { + if !active { + return; + } + emit_state_aware_event(ctx, outcome, elapsed); shutdown(); } diff --git a/src/core/wxc_common/src/wire.rs b/src/core/wxc_common/src/wire.rs index 21dcfe8f6..42120ff56 100644 --- a/src/core/wxc_common/src/wire.rs +++ b/src/core/wxc_common/src/wire.rs @@ -71,7 +71,7 @@ pub struct MxcConfig { /// distinct), passes an already-frozen vector through unchanged, and reseeds a /// brand-new base if the relayed value is absent or malformed — so a missing /// or hostile relay never reaches telemetry unvalidated. Ignored unless - /// experimental telemetry is enabled; not valid on one-shot requests. + /// telemetry is enabled; not valid on one-shot requests. pub correlation_vector: Option, /// Externally assigned container identifier. @@ -113,6 +113,9 @@ pub struct MxcConfig { #[serde(alias = "macos_sandbox")] pub seatbelt: Option, + /// Telemetry configuration. + pub telemetry: Option, + /// Experimental features. Only honored when `--experimental` is passed. pub experimental: Option, } @@ -467,11 +470,9 @@ pub struct Experimental { /// Seatbelt backend config (pre-promotion alias). #[serde(alias = "macos_sandbox")] pub seatbelt: Option, - /// Telemetry configuration. - pub telemetry: Option, } -/// Telemetry configuration (`experimental.telemetry`). +/// Telemetry configuration (`telemetry`). #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase")] diff --git a/src/ffi/mxc_ffi/src/lib.rs b/src/ffi/mxc_ffi/src/lib.rs index f97da40ff..244abf0aa 100644 --- a/src/ffi/mxc_ffi/src/lib.rs +++ b/src/ffi/mxc_ffi/src/lib.rs @@ -262,6 +262,25 @@ pub(crate) unsafe fn cstr_to_str<'a>(p: *const c_char) -> Option<&'a str> { CStr::from_ptr(p).to_str().ok() } +pub(crate) fn parse_policy_json( + policy_json: &str, +) -> Result<(SandboxPolicy, Option), String> { + let value: serde_json::Value = serde_json::from_str(policy_json) + .map_err(|error| format!("failed to parse policy JSON: {error}"))?; + let telemetry_enabled = match value.get("telemetryEnabled") { + None | Some(serde_json::Value::Null) => None, + Some(serde_json::Value::Bool(enabled)) => Some(*enabled), + Some(_) => { + return Err( + "failed to parse policy JSON: telemetryEnabled must be a boolean".to_string(), + ) + } + }; + let policy = serde_json::from_value(value) + .map_err(|error| format!("failed to parse policy JSON: {error}"))?; + Ok((policy, telemetry_enabled)) +} + // --------------------------------------------------------------------------- // Entry points // --------------------------------------------------------------------------- @@ -324,14 +343,9 @@ fn run_inner(policy_json_utf8: *const c_char, command_utf8: *const c_char) -> Mx None => return MxcRunResult::error(MXC_STATUS_INVALID_UTF8, "command is not UTF-8"), }; - let policy: SandboxPolicy = match serde_json::from_str(policy_json) { - Ok(p) => p, - Err(e) => { - return MxcRunResult::error( - MXC_STATUS_MALFORMED_REQUEST, - format!("failed to parse policy JSON: {e}"), - ) - } + let (policy, telemetry_enabled) = match parse_policy_json(policy_json) { + Ok(parsed) => parsed, + Err(error) => return MxcRunResult::error(MXC_STATUS_MALFORMED_REQUEST, error), }; let mut request = match build_request(&policy, None) { @@ -339,6 +353,9 @@ fn run_inner(policy_json_utf8: *const c_char, command_utf8: *const c_char) -> Mx Err(e) => return MxcRunResult::error(status_from_error_code(e.code), e.message), }; request.set_script(command); + if let Some(enabled) = telemetry_enabled { + request.set_telemetry_enabled(enabled); + } match run(request) { Ok(output) => { @@ -611,6 +628,24 @@ mod tests { out } + #[test] + fn policy_telemetry_switch_is_applied_outside_public_policy_shape() { + let (policy, telemetry_enabled) = + parse_policy_json(r#"{"version":"0.8.0-alpha","telemetryEnabled":true}"#) + .expect("policy should parse"); + + assert_eq!(policy.version, "0.8.0-alpha"); + assert_eq!(telemetry_enabled, Some(true)); + } + + #[test] + fn policy_telemetry_switch_rejects_non_boolean_values() { + let error = parse_policy_json(r#"{"version":"0.8.0-alpha","telemetryEnabled":"yes"}"#) + .expect_err("non-boolean telemetry switch must fail"); + + assert!(error.contains("telemetryEnabled must be a boolean")); + } + #[test] fn malformed_policy_json_reports_malformed_request() { let mut out = run_with("{ not json", Some("echo hi")); diff --git a/src/ffi/mxc_ffi/src/streaming.rs b/src/ffi/mxc_ffi/src/streaming.rs index 3bc0ead5d..952c405ad 100644 --- a/src/ffi/mxc_ffi/src/streaming.rs +++ b/src/ffi/mxc_ffi/src/streaming.rs @@ -54,12 +54,12 @@ use std::io::{Read, Write}; use std::panic::{catch_unwind, AssertUnwindSafe}; use std::ptr; -use mxc_sdk::{build_request, spawn_sandbox, Sandbox, SandboxPolicy, WaitOutcome}; +use mxc_sdk::{build_request, spawn_sandbox, Sandbox, WaitOutcome}; use crate::{ - alloc_cstring, cstr_to_str, status_from_error_code, MXC_STATUS_BACKEND_ERROR, - MXC_STATUS_INVALID_UTF8, MXC_STATUS_MALFORMED_REQUEST, MXC_STATUS_NULL_ARGUMENT, - MXC_STATUS_PANIC, MXC_STATUS_SUCCESS, + alloc_cstring, cstr_to_str, parse_policy_json, status_from_error_code, + MXC_STATUS_BACKEND_ERROR, MXC_STATUS_INVALID_UTF8, MXC_STATUS_MALFORMED_REQUEST, + MXC_STATUS_NULL_ARGUMENT, MXC_STATUS_PANIC, MXC_STATUS_SUCCESS, }; // --------------------------------------------------------------------------- @@ -201,16 +201,15 @@ fn spawn_inner( None => return Err((MXC_STATUS_INVALID_UTF8, "command is not UTF-8".into())), }; - let policy: SandboxPolicy = serde_json::from_str(policy_json).map_err(|e| { - ( - MXC_STATUS_MALFORMED_REQUEST, - format!("failed to parse policy JSON: {e}"), - ) - })?; + let (policy, telemetry_enabled) = + parse_policy_json(policy_json).map_err(|error| (MXC_STATUS_MALFORMED_REQUEST, error))?; let mut request = build_request(&policy, None).map_err(|e| (status_from_error_code(e.code), e.message))?; request.set_script(command); + if let Some(enabled) = telemetry_enabled { + request.set_telemetry_enabled(enabled); + } spawn_sandbox(request).map_err(|e| (status_from_error_code(e.code), e.message)) } diff --git a/src/mxc_telemetry/src/lib.rs b/src/mxc_telemetry/src/lib.rs index dd37abf39..f42d4a854 100644 --- a/src/mxc_telemetry/src/lib.rs +++ b/src/mxc_telemetry/src/lib.rs @@ -60,7 +60,7 @@ mod provider { /// concurrent `init`/`shutdown` race from leaving the wrapper flag and the /// provider's real state out of sync (e.g. flag set to registered after a /// `register()` that actually failed, or a double register/unregister). - static REGISTERED: Mutex = Mutex::new(false); + static REGISTERED: Mutex = Mutex::new(0); /// Lock-free "is the provider currently registered" flag. /// @@ -100,9 +100,10 @@ mod provider { }); let mut registered = REGISTERED.lock().unwrap_or_else(|e| e.into_inner()); - if *registered { + if *registered > 0 { // Already registered — the tracelogging crate panics on double - // register, so we must not call `register()` again. + // register, so retain a reference instead. + *registered += 1; return true; } @@ -110,12 +111,12 @@ mod provider { // executable (not a DLL), so unload ordering is not a concern. let status = unsafe { MXC_PROVIDER.register() }; if status != 0 { - // Registration failed; leave `*registered` false so the wrapper + // Registration failed; leave the reference count at zero so the wrapper // state matches reality and a later attempt can retry. return false; } - *registered = true; + *registered = 1; ACTIVE.store(true, Ordering::Release); true } @@ -123,9 +124,11 @@ mod provider { /// Unregister the ETW provider. pub fn shutdown() { let mut registered = REGISTERED.lock().unwrap_or_else(|e| e.into_inner()); - if *registered { + if *registered > 1 { + *registered -= 1; + } else if *registered == 1 { MXC_PROVIDER.unregister(); - *registered = false; + *registered = 0; ACTIVE.store(false, Ordering::Release); } } @@ -321,6 +324,8 @@ mod tests { let _ = init("0.0.0-test", "dev"); let _ = init("0.0.0-test", "dev"); shutdown(); + assert!(is_active(), "one retained registration must remain active"); + shutdown(); } #[test] diff --git a/src/testing/wxc_e2e_tests/tests/e2e_windows.rs b/src/testing/wxc_e2e_tests/tests/e2e_windows.rs index b388fd363..a0ea5587c 100644 --- a/src/testing/wxc_e2e_tests/tests/e2e_windows.rs +++ b/src/testing/wxc_e2e_tests/tests/e2e_windows.rs @@ -575,7 +575,7 @@ fn test_on_repeat() { // --------------------------------------------------------------------------- fn telemetry_enabled() { - let result = run_wxc_example("28_telemetry_enabled.json", &["--debug", "--experimental"]); + let result = run_wxc_example("28_telemetry_enabled.json", &["--debug"]); assert_success_or_skip_missing_prerequisite(&result); } diff --git a/tests/examples/28_telemetry_enabled.json b/tests/examples/28_telemetry_enabled.json index 7ac84537b..9bfa702bf 100644 --- a/tests/examples/28_telemetry_enabled.json +++ b/tests/examples/28_telemetry_enabled.json @@ -4,9 +4,7 @@ "process": { "commandLine": "cmd.exe /c echo Hello from telemetry-enabled sandbox" }, - "experimental": { - "telemetry": { - "enabled": true - } + "telemetry": { + "enabled": true } } diff --git a/tests/scripts/run_telemetry_etw_smoke_test.ps1 b/tests/scripts/run_telemetry_etw_smoke_test.ps1 index 7c0243948..822b1166e 100644 --- a/tests/scripts/run_telemetry_etw_smoke_test.ps1 +++ b/tests/scripts/run_telemetry_etw_smoke_test.ps1 @@ -132,13 +132,13 @@ Write-Host "ETW session started, writing to $etlFile" Write-Host "`n--- Running wxc-exec with telemetry ---" -ForegroundColor Yellow try { - # Run with --experimental to enable the telemetry section. The provider is - # registered during init (before execution); the MXC.Execution / MXC.Error + # The provider is registered during init (before execution); the + # MXC.Execution / MXC.Error # events are emitted on completion, after the runner returns. The sandbox # itself may fail (e.g. AppContainer prerequisites), but completion # telemetry still fires for the failure, so events should be captured. $proc = Start-Process -FilePath $wxcExe ` - -ArgumentList "--debug", "--experimental", $configFile ` + -ArgumentList "--debug", $configFile ` -PassThru -NoNewWindow -Wait Write-Host "wxc-exec exited with code $($proc.ExitCode)" } catch {