diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 909cda82b..6210573fa 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: @@ -230,6 +233,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 and runs in the versioning checks workflow. +- **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. @@ -243,6 +258,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/.github/workflows/Build.Windows.Job.yml b/.github/workflows/Build.Windows.Job.yml index 9f7c5c5be..644506f4e 100644 --- a/.github/workflows/Build.Windows.Job.yml +++ b/.github/workflows/Build.Windows.Job.yml @@ -66,11 +66,32 @@ jobs: --no-default-features --features "${{ matrix.features }}" + - name: Test telemetry consent and policy in release mode + run: | + cargo test --locked --release --target ${{ matrix.target }} -p wxc_common telemetry::consent + cargo test --locked --release --target ${{ matrix.target }} -p wxc_common telemetry::policy + - name: Test run: cargo test --locked --release --target ${{ matrix.target }} --no-default-features --features "${{ matrix.features }}" + - name: Build debug executor for isolated telemetry smoke tests + if: matrix.arch == 'x64' + run: cargo build --locked -p wxc + + - name: Run isolated telemetry consent smoke test + if: matrix.arch == 'x64' + working-directory: ${{ github.workspace }} + shell: pwsh + run: tests\scripts\run_telemetry_consent_smoke_test.ps1 -BinDir src\target\debug + + - name: Run isolated telemetry ETW smoke test + if: matrix.arch == 'x64' + working-directory: ${{ github.workspace }} + shell: pwsh + run: tests\scripts\run_telemetry_etw_smoke_test.ps1 -BinDir src\target\debug + - name: Upload binaries uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/Versioning.Checks.Job.yml b/.github/workflows/Versioning.Checks.Job.yml index 17c34983a..84afc5563 100644 --- a/.github/workflows/Versioning.Checks.Job.yml +++ b/.github/workflows/Versioning.Checks.Job.yml @@ -50,5 +50,17 @@ jobs: - name: Check SDK wire types are in sync with the Rust wire model (codegen) run: node scripts/versioning/check-sdk-types-codegen.js + - name: Check telemetry consent maintenance artifacts (codegen) + run: node scripts/versioning/check-telemetry-consent-codegen.js + + - name: Check telemetry policy binding parity + run: node scripts/check-telemetry-policy-parity.js + + - name: Check .NET error-code parity + run: node scripts/check-dotnet-errorcode-parity.js + + - name: Check .NET native bindings (codegen) + run: node scripts/check-dotnet-bindings-codegen.js + - name: Validate config corpus against dev schema run: node scripts/versioning/validate-configs.js diff --git a/README.md b/README.md index d80f33e4a..203132fde 100644 --- a/README.md +++ b/README.md @@ -227,34 +227,62 @@ 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 (`MXC.Execution` and `MXC.Error`) are emitted to the local ETW subsystem via the Rust [`tracelogging`](https://crates.io/crates/tracelogging) crate. Every event includes common fields (Version, Channel, IsDebugging, `UTCReplace_AppSessionGuid`) as Part C custom event data. +MXC supports optional TraceLogging ETW telemetry for execution observability. When enabled, structured events (`MXC.Execution` and `MXC.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 +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 +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 -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. -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 `MXC.Execution` and `MXC.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 presenter-bound consent requests, +typed consent status, and explicit withdrawal. SDK hosts render the canonical +MXC consent resource through their native UI and return the user's decision; +MXC owns persistence. See the normative +[SDK presenter requirements](docs/telemetry/telemetry-consent-design.md#sdk-presenter-requirements). 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/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 new file mode 100644 index 000000000..4c3296c91 --- /dev/null +++ b/docs/telemetry/telemetry-consent-design.md @@ -0,0 +1,289 @@ +# Telemetry consent design + +MXC telemetry is Windows-only and fails closed. Collection requires all four +conditions at the time each event is written: + +```text +per-run telemetry requested +&& current-version user consent is Granted +&& administrative policy does not block +&& the platform/provider is available +``` + +No prompt, no response, No, dismissal, withdrawal, malformed state, an unknown +prompt version, or any read/write failure means no telemetry. Administrative +policy is a deny-only ceiling: it may block collection but can never opt a user +in. + +## Canonical consent resource + +Rust owns one immutable, versioned resource in +`wxc_common::telemetry::consent_prompt`. Every EXE and SDK presenter must show +every supplied field verbatim. Hosts control layout, accessibility, and native +UI, but may not substitute wording. + +Current resource: + +- Resource version: `1` +- Locale: `en-US` +- Mandatory fallback: `en-US` +- Privacy link: + +### Title + +> Help improve Microsoft eXecution Container (MXC) + +### Body + +> Help improve MXC by sharing optional diagnostic data with Microsoft. +> If enabled, MXC sends diagnostic information about product usage, +> performance, and reliability. MXC does not send your commands, file paths, +> credentials, or other customer content. +> You can change your choice at any time. + +### Actions + +- Affirmative: **Yes** +- Negative: **No** +- Learn more: **Privacy Statement** + +Each field has a stable message ID. Locale lookup normalizes BCP 47 tags and +falls back to `en-US`; complete messages are translated as units rather than +assembled from fragments. Adding a translation does not change the public API. +A material wording or data-inventory change requires a new resource version and +explicit re-consent. + +## SDK presenter requirements + +An SDK host owns the native presentation experience, while MXC owns the +resource, decision validation, and persistence. Implementers must follow this +contract: + +1. Call the SDK consent-request API with a presenter callback. Do not write the + consent store directly or create a separate grant path. +2. Render every supplied field verbatim: title, body, affirmative label, + negative label, learn-more label, and learn-more URL. Hosts may control + layout, accessibility, and platform-native styling, but must not hardcode, + shorten, reorder within a message, or replace the supplied wording. +3. Map the affirmative control to `Yes` and the negative control to `No`. +4. Map window close, cancel, timeout, or no response to `Dismissed`. If the UI + cannot be presented or otherwise fails, return a presenter error instead. + Never infer `Yes` from a default button, policy, prior application + preference, or absence of a response. +5. Make the learn-more control open the supplied URL, normally in the user's + default browser. Do not substitute a different privacy destination. +6. Return only the typed decision to MXC. MXC persists the decision together + with the resource version and locale that were actually presented. +7. Use the typed status API to explain the current stored/effective state and + administrative ceiling. Provide an explicit withdrawal control in the + host's settings or other appropriate telemetry-controls surface. + +MXC does not invoke the presenter when the result is already determined: + +- `AlreadyGranted`: the current resource version is already granted. +- `PolicyBlocked`: administrative policy prevents collection. +- `NotApplicable`: telemetry is unavailable on this platform. + +If the host never calls the request API, telemetry remains off. A presenter +failure must be surfaced as an error to the host and must not create or alter a +grant. + +## State and persistence + +MXC stores consent per user at: + +```text +%LOCALAPPDATA%\mxc\telemetry-consent.json +``` + +Schema version 2 records: + +- `consent`: `granted` or `denied` +- `promptResourceVersion` +- `promptLocale` +- MXC version, source, and update timestamp for local audit/support provenance + +Stored and effective state are reported separately. A legacy grant without the +current prompt version remains visible as `storedState: "granted"` but is +`effectiveState: "undetermined"` with reason `prompt-version-missing` or +`prompt-version-unsupported`. It never authorizes collection. Legacy denial +remains denied. + +Only two operations mutate the store: + +1. A presenter invoked by MXC returns an explicit `yes` or `no` for the prompt + supplied in that same invocation. +2. The explicit withdrawal operation persists `denied`. + +Dismissal, EOF, presenter failure, localization fallback failure, or never +requesting consent does not fabricate or rewrite a decision. + +## Administrative ceiling + +Policy is read from: + +```text +HKLM\SOFTWARE\Policies\Mxc\AllowTelemetry (REG_DWORD) +``` + +Only value `3` permits MXC optional diagnostic data. Other values, wrong value +types, unreadable state, and ambiguity fail closed to `blocked`. An absent key +is `unrestricted`. `allowed` and `unrestricted` merely leave the user's choice +in control; neither creates consent. + +A consent request made while policy is blocked does not invoke the presenter or +persist dormant consent. Withdrawal remains available while blocked. + +## Per-run enablement + +Consent is not an execution switch. Each run must also request telemetry: + +- JSON execution config: top-level `telemetry.enabled: true` +- Rust SDK: `SandboxRequest::set_telemetry_enabled(true)` +- C# and Node policies serialize the same top-level execution field + +`telemetry.enabled: true` never prompts. Runs continue normally when telemetry +is disabled or unauthorized. + +## EXE maintenance JSON + +State-changing consent flags do not exist. The old grant/revoke/source +spellings are non-mutating migration tombstones that return an actionable +error. + +Consent administration uses the ordinary JSON input loader with a separate +closed maintenance contract: + +```json +{ + "$schema": "https://raw.githubusercontent.com/microsoft/mxc/main/schemas/dev/mxc-telemetry-consent.schema.1.json", + "command": "telemetryConsent", + "action": "request", + "locale": "en-US" +} +``` + +Actions: + +- `request`: invoke a presenter when needed +- `withdraw`: idempotently persist `denied` +- `status`: read only + +`--telemetry-consent-status` remains a read-only convenience and emits the same +typed JSON response as `action: "status"`. + +Responses include: + +```json +{ + "action": "status", + "result": "status", + "storedState": "undetermined", + "effectiveState": "undetermined", + "reason": "no-record", + "policy": "unrestricted", + "needsPrompt": true +} +``` + +The maintenance schema is generated separately from Rust at +`schemas/dev/mxc-telemetry-consent.schema.1.json`. The execution schema remains +execution-only. + +### Interactive EXE + +For a terminal request, `wxc-exec` renders the canonical resource and accepts +only an explicit affirmative or negative choice. Noninteractive input, EOF, or +unavailable presentation returns a typed non-grant result without launching a +sandbox. + +### Node presenter protocol + +Node uses a private same-process stdio handshake: + +1. The child emits the canonical prompt and a random 32-byte challenge. +2. Node invokes the host presenter. +3. Node returns one typed decision with that challenge and prompt version. +4. The same child verifies both values before persisting. + +Replay, cross-process responses, and prompt-version mismatches are rejected. +This protocol is internal and does not expose a detached begin/complete API. + +## Public SDK surfaces + +### Rust + +```rust,no_run +use mxc_sdk::telemetry::{self, ConsentDecision}; + +let outcome = telemetry::request_consent(Some("en-US"), |prompt| { + // Render every field verbatim. + Ok(ConsentDecision::Yes) +})?; + +let status = telemetry::get_consent_status(); +let withdrawal = telemetry::withdraw_consent()?; +# let _ = (outcome, status, withdrawal); +# Ok::<(), telemetry::ConsentError>(()) +``` + +`request_consent_async` provides the asynchronous presenter variant. + +### C# + +```csharp +var outcome = MxcTelemetry.RequestConsent(prompt => +{ + // Render every field verbatim. + return TelemetryConsentDecision.Yes; +}); + +TelemetryConsentStatus status = MxcTelemetry.GetConsentStatus(); +MxcTelemetry.WithdrawConsent(); +``` + +`RequestConsentAsync` accepts an asynchronous presenter. + +### Node.js + +```typescript +const outcome = await requestTelemetryConsent(async (prompt) => { + // Render every field verbatim. + return 'yes'; +}, 'en-US'); + +const status = queryTelemetryConsent(); +withdrawTelemetryConsent(); +``` + +The presenter may return a decision directly or a promise. Node never writes +the consent store itself. + +## Live enforcement and event compatibility + +Consent and policy are checked immediately before every `MXC.Execution` and +`MXC.Error` event write. A withdrawal or newly blocking policy therefore stops +later writes from an already-running process even if the provider was +registered earlier. + +The public ETW event identities remain: + +- `MXC.Execution` +- `MXC.Error` + +Collectors may continue filtering those names; consent stabilization does not +rename the provider or events. + +## Validation and release gate + +CI enforces: + +- telemetry policy strings across Rust, C#, and generated TypeScript +- maintenance schema/TypeScript codegen +- C ABI/C# binding codegen and error-code parity +- named release-mode consent and policy tests +- isolated debug consent and ETW smoke tests + +The versioned wording and data/control inventory require Microsoft +privacy/legal approval before release. See +[`telemetry-consent-privacy-review.md`](telemetry-consent-privacy-review.md). diff --git a/docs/telemetry/telemetry-consent-privacy-review.md b/docs/telemetry/telemetry-consent-privacy-review.md new file mode 100644 index 000000000..da28e38b9 --- /dev/null +++ b/docs/telemetry/telemetry-consent-privacy-review.md @@ -0,0 +1,67 @@ +# Telemetry consent privacy review package + +**Status:** Consent resource version 1 wording approved by the privacy team. + +## Product and purpose + +Microsoft eXecution Container (MXC) is a sandboxed code-execution system. +Optional diagnostic data is used to understand product usage, diagnose bounded +failure categories, and improve reliability. + +## Canonical disclosure + +Review the exact version 1 `en-US` title, body, action labels, and privacy link +in [`telemetry-consent-design.md`](telemetry-consent-design.md#canonical-consent-resource). +All EXE and SDK surfaces receive that Rust-owned resource and must render every +field verbatim. + +## Data sent + +- MXC version and channel +- Containment backend +- Run outcome and exit code +- Run duration +- Bounded failure category +- Lifecycle phase +- Random app-session and sandbox-lifecycle correlation identifiers + +## Data explicitly excluded + +- Command text +- File paths +- Environment variables +- Standard input or output +- Usernames +- Credentials +- Free-form error messages + +## Controls + +- Collection is Windows-only. +- Telemetry is off unless each run explicitly requests it. +- No consent request, No, dismissal, missing response, malformed state, or any + error means no collection. +- Only an explicit Yes returned from the presenter invocation that received the + current canonical resource creates a grant. +- Users can withdraw through MXC telemetry consent controls at any time. +- Administrative policy is a deny-only ceiling and cannot opt a user in. +- Consent and policy are rechecked immediately before every event. +- Legacy or unknown-version grants require explicit re-consent. + +## Persistence and retention boundary + +MXC stores the user's local decision, prompt resource version, locale, MXC +version, source, and update timestamp in the per-user consent file. This local +record is not telemetry payload. Service-side diagnostic-data retention and +access controls must be confirmed by the receiving Microsoft telemetry system +owner as part of approval. + +## Remaining reviewer decisions + +1. Confirm the listed included/excluded data categories. +2. Confirm service-side retention, access, regional, and deletion requirements. +3. Confirm whether additional locale or accessibility requirements apply at + initial stable release. + +Any material wording or collected-field change after approval requires a new +resource version and explicit re-consent. diff --git a/docs/telemetry/telemetry-policy.md b/docs/telemetry/telemetry-policy.md new file mode 100644 index 000000000..d1cd4e35f --- /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 +{"action":"status","result":"status","storedState":"granted","effectiveState":"granted","reason":null,"policy":"blocked","needsPrompt":false,"prompt":null,"challenge":null} +``` + +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..3cc96f8df 100644 --- a/docs/telemetry/telemetry.md +++ b/docs/telemetry/telemetry.md @@ -61,6 +61,11 @@ shared Part C custom event fields: ## Events +The provider-qualified uploaded event identities are +`Microsoft.MXC/MXC.Execution` and `Microsoft.MXC/MXC.Error`. `Microsoft.MXC` +is the TraceLogging provider name; `MXC.Execution` and `MXC.Error` are the +event names. + ### MXC.Execution Emitted when a one-shot execution completes (success or failure). It is also @@ -85,6 +90,8 @@ the one-shot path, a clean non-zero sandbox exit is not treated as an MXC error. | `mxc.phase` | string | State-aware lifecycle phase (`provision`\|`start`\|`exec`\|`stop`\|`deprovision`); empty for one-shot executions | | `__TlgCV__` | string | Microsoft Correlation Vector (MS-CV) — the lifecycle correlation key (see [Correlating a lifecycle](#correlating-a-lifecycle)); empty for one-shot executions | +### MXC.Error + Emitted on execution errors. | Field | Type | Description | @@ -158,7 +165,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 @@ -231,6 +238,67 @@ 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 top-level `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). +- Consent request/withdraw/status operations use the typed JSON maintenance + envelope documented in the consent design. The old grant/revoke/source flags + are non-mutating migration tombstones. `--telemetry-consent-status` remains + read-only and emits the same typed response as JSON `action: "status"`. +- Every EXE/SDK presenter receives the exact Rust-owned, versioned `en-US` + resource. Only an explicit Yes returned from that invocation may create a + current grant. No request, No, dismissal, presenter failure, or legacy grant + means no collection. +- Consent and policy are rechecked immediately before every event write, so + withdrawal or a new administrative block stops later events in a long-lived + process. +- 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 bf42b24e7..e4a5526b2 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,10 +707,11 @@ "type": "object" }, "Telemetry": { - "description": "Telemetry configuration (`experimental.telemetry`).", + "additionalProperties": false, + "description": "Telemetry configuration (`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" @@ -919,7 +909,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 +1032,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/schemas/dev/mxc-telemetry-consent.schema.1.json b/schemas/dev/mxc-telemetry-consent.schema.1.json new file mode 100644 index 000000000..984eaeb43 --- /dev/null +++ b/schemas/dev/mxc-telemetry-consent.schema.1.json @@ -0,0 +1,79 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/microsoft/mxc/schemas/dev/mxc-telemetry-consent.schema.1.json", + "title": "MXC Telemetry Consent Maintenance Request", + "description": "Telemetry-consent maintenance request.\n\nThis is a separate contract from [`MxcConfig`], even though executors use the same JSON input loader for both. Its explicit `command` discriminator lets the loader route maintenance without widening the execution schema.", + "additionalProperties": false, + "definitions": { + "TelemetryConsentAction": { + "description": "Telemetry-consent maintenance action.", + "oneOf": [ + { + "description": "Present the canonical prompt when consent may be requested.", + "enum": [ + "request" + ], + "type": "string" + }, + { + "description": "Idempotently persist denied consent.", + "enum": [ + "withdraw" + ], + "type": "string" + }, + { + "description": "Read status without mutation.", + "enum": [ + "status" + ], + "type": "string" + } + ] + }, + "TelemetryConsentCommand": { + "description": "Fixed telemetry-consent maintenance command discriminator.", + "enum": [ + "telemetryConsent" + ], + "type": "string" + } + }, + "properties": { + "$schema": { + "description": "Optional JSON Schema reference for editor validation.", + "type": [ + "string", + "null" + ] + }, + "action": { + "allOf": [ + { + "$ref": "#/definitions/TelemetryConsentAction" + } + ], + "description": "Consent operation to perform." + }, + "command": { + "allOf": [ + { + "$ref": "#/definitions/TelemetryConsentCommand" + } + ], + "description": "Fixed maintenance discriminator. Must be `\"telemetryConsent\"`." + }, + "locale": { + "description": "Preferred BCP 47 locale for the canonical prompt. Unsupported locales fall back to `en-US`. Used only by `request`.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "action", + "command" + ], + "type": "object" +} diff --git a/scripts/check-dotnet-bindings-codegen.js b/scripts/check-dotnet-bindings-codegen.js index 3e174f786..8cb28c060 100644 --- a/scripts/check-dotnet-bindings-codegen.js +++ b/scripts/check-dotnet-bindings-codegen.js @@ -34,6 +34,12 @@ const REQUIRED_ENTRY_POINTS = [ "mxc_run_result_free", "mxc_string_free", "mxc_version", + "mxc_telemetry_get_consent", + "mxc_telemetry_request_consent", + "mxc_telemetry_withdraw_consent", + "mxc_telemetry_get_consent_status", + "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..4707dcc09 --- /dev/null +++ b/scripts/check-telemetry-policy-parity.js @@ -0,0 +1,168 @@ +#!/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", + "generated", + "telemetry-consent-wire.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. 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]*?)\};/ +); +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|UnrecognizedPolicyState\(value\))/.test( + parseBody[1] +); +if (!csharpDefault) { + errors.push( + "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"); + +// --- TypeScript: the exported union --------------------------------------- +const tsSrc = readFileSync(tsPath, "utf8"); +const unionMatch = tsSrc.match( + /export type TelemetryConsentPolicyState\s*=\s*([^;]+);/ +); +if (!unionMatch) { + console.error( + "ERROR: could not find generated `TelemetryConsentPolicyState`" + ); + 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/scripts/versioning/check-telemetry-consent-codegen.js b/scripts/versioning/check-telemetry-consent-codegen.js new file mode 100644 index 000000000..9a696c30b --- /dev/null +++ b/scripts/versioning/check-telemetry-consent-codegen.js @@ -0,0 +1,79 @@ +#!/usr/bin/env node +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +const { execFileSync } = require("child_process"); +const { readFileSync, mkdtempSync, rmSync } = require("fs"); +const { join } = require("path"); +const os = require("os"); + +const repoRoot = join(__dirname, "..", ".."); +const artifacts = [ + { + args: ["--telemetry-consent"], + committed: join( + repoRoot, + "schemas", + "dev", + "mxc-telemetry-consent.schema.1.json" + ), + name: "telemetry consent schema", + }, + { + args: ["--telemetry-consent-ts"], + committed: join( + repoRoot, + "sdk", + "node", + "src", + "generated", + "telemetry-consent-wire.ts" + ), + name: "telemetry consent TypeScript wire types", + }, +]; + +function normalize(content) { + return content.replace(/\r\n/g, "\n"); +} + +function fail(message) { + console.error("Telemetry consent codegen check FAILED:"); + console.error(` - ${message}`); + process.exit(1); +} + +const tempDir = mkdtempSync(join(os.tmpdir(), "mxc-consent-codegen-")); +try { + for (const [index, artifact] of artifacts.entries()) { + const output = join(tempDir, `artifact-${index}`); + execFileSync( + "cargo", + ["run", "-q", "-p", "mxc_schema_gen", "--", ...artifact.args, output], + { + cwd: join(repoRoot, "src"), + stdio: ["ignore", "ignore", "inherit"], + } + ); + + let committed; + try { + committed = readFileSync(artifact.committed, "utf8"); + } catch (error) { + fail(`could not read ${artifact.committed}: ${error.message}`); + } + const generated = readFileSync(output, "utf8"); + if (normalize(generated) !== normalize(committed)) { + fail( + `${artifact.name} is stale at ${artifact.committed}. ` + + `Regenerate it with mxc_schema_gen ${artifact.args.join(" ")}.` + ); + } + } +} finally { + rmSync(tempDir, { recursive: true, force: true }); +} + +console.log( + "Telemetry consent codegen OK: schema and TypeScript wire types match Rust." +); diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcLifecycleTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcLifecycleTests.cs index 3d1d8898b..e3827f0c4 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcLifecycleTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcLifecycleTests.cs @@ -182,4 +182,51 @@ 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"]); + } + + [Fact] + public void ExecInSandboxAsync_PreservesCancellationTokenAsThirdParameter() + { + static Task InvokeWithDefaultLiteral(SandboxId id, string command) => + MxcLifecycle.ExecInSandboxAsync(id, command, default); + + Assert.NotNull((Func>)InvokeWithDefaultLiteral); + } } 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..55bd99bb9 --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs @@ -0,0 +1,494 @@ +// 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":2,"consent":"{{consent}}","source":"test","promptedMxcVersion":"0.0.0","promptResourceVersion":1,"promptLocale":"en-US","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 RequestConsent_ThenWithdraw_RoundTrips_OnWindows() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + var outcome = MxcTelemetry.RequestConsent(prompt => + { + Assert.Equal(1u, prompt.ResourceVersion); + Assert.Equal("en-US", prompt.Locale); + Assert.Equal("Help improve Microsoft eXecution Container (MXC)", prompt.Title.Text); + Assert.Equal( + """ + Help improve MXC by sharing optional diagnostic data with Microsoft. + If enabled, MXC sends diagnostic information about product usage, performance, and reliability. MXC does not send your commands, file paths, credentials, or other customer content. + You can change your choice at any time. + """.ReplaceLineEndings("\n"), + prompt.Body.Text); + Assert.Equal("Yes", prompt.AffirmativeLabel.Text); + Assert.Equal("No", prompt.NegativeLabel.Text); + Assert.Equal("Privacy Statement", prompt.LearnMoreLabel.Text); + Assert.Equal("https://go.microsoft.com/fwlink/?linkid=521839", prompt.LearnMoreUrl); + return TelemetryConsentDecision.Yes; + }); + Assert.Equal(TelemetryConsentActionResult.Granted, outcome.Result); + Assert.Equal(TelemetryConsentState.Granted, MxcTelemetry.GetConsent()); + + var withdrawal = MxcTelemetry.WithdrawConsent(); + Assert.Equal(TelemetryConsentActionResult.Withdrawn, withdrawal.Result); + 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; + } + + var denied = MxcTelemetry.RequestConsent(_ => TelemetryConsentDecision.No); + Assert.Equal(TelemetryConsentActionResult.Denied, denied.Result); + Assert.False(MxcTelemetry.NeedsConsentPrompt()); + + var granted = MxcTelemetry.RequestConsent(_ => TelemetryConsentDecision.Yes); + Assert.Equal(TelemetryConsentActionResult.Granted, granted.Result); + Assert.False(MxcTelemetry.NeedsConsentPrompt()); + } + + [Fact] + public void RequestConsent_NonWindows_IsNotApplicableAndDoesNotPresent() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + var presented = false; + var outcome = MxcTelemetry.RequestConsent(_ => + { + presented = true; + return TelemetryConsentDecision.Yes; + }); + Assert.False(presented); + Assert.Equal(TelemetryConsentActionResult.NotApplicable, outcome.Result); + } + + [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()); + + var presented = false; + var blocked = MxcTelemetry.RequestConsent(_ => + { + presented = true; + return TelemetryConsentDecision.Yes; + }); + Assert.False(presented); + Assert.Equal(TelemetryConsentActionResult.PolicyBlocked, blocked.Result); + Assert.Equal(TelemetryConsentState.Undetermined, MxcTelemetry.GetConsent()); + Assert.Equal(TelemetryPolicyState.Blocked, MxcTelemetry.GetPolicy()); + + SetPolicyValue(null); + Assert.Equal(TelemetryPolicyState.Unrestricted, MxcTelemetry.GetPolicy()); + var granted = MxcTelemetry.RequestConsent(_ => TelemetryConsentDecision.Yes); + Assert.Equal(TelemetryConsentActionResult.Granted, granted.Result); + Assert.Equal(TelemetryConsentState.Granted, MxcTelemetry.GetConsent()); + } + + [Fact] + public async Task RequestConsentAsync_PersistsPresenterDecision_OnWindows() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + var outcome = await MxcTelemetry.RequestConsentAsync( + prompt => ValueTask.FromResult( + prompt.Locale == "en-US" + ? TelemetryConsentDecision.Yes + : TelemetryConsentDecision.Dismissed)); + Assert.Equal(TelemetryConsentActionResult.Granted, outcome.Result); + 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); + + var status = Record.Exception(() => MxcTelemetry.GetConsentStatus()); + Assert.Null(status); + } + + [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/MxcLifecycle.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs index e724d2966..3e67d1dd7 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, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + StateAwareOperationOptions? options = null) { // 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 { @@ -188,19 +199,23 @@ public static async Task ExecInSandboxAsync( /// 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 +227,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/MxcTelemetry.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs new file mode 100644 index 000000000..6bff4130c --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs @@ -0,0 +1,596 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Runtime.InteropServices; +using System.Runtime.CompilerServices; +using System.Text.Json; +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 invokes a host presenter with the +/// complete canonical prompt. Only the typed decision returned from that +/// invocation may be persisted. A host that never requests consent leaves +/// telemetry off. remains available at any time. +/// +/// 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 a consent request 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; + } + } + + private sealed class PresenterContext + { + internal required Func> Presenter { get; init; } + internal Exception? Error { get; set; } + } + + /// Request consent through a synchronous host presenter. + public static TelemetryConsentOutcome RequestConsent( + Func presenter, + string? locale = null) + { + ArgumentNullException.ThrowIfNull(presenter); + return RequestConsentCore(prompt => ValueTask.FromResult(presenter(prompt)), locale); + } + + /// Request consent through an asynchronous host presenter. + public static Task RequestConsentAsync( + Func> presenter, + string? locale = null) + { + ArgumentNullException.ThrowIfNull(presenter); + return Task.Run(() => RequestConsentCore(presenter, locale)); + } + + /// Read stored/effective consent and the administrative ceiling. + public static TelemetryConsentStatus GetConsentStatus() + { + if (!OperatingSystem.IsWindows()) + { + return new( + TelemetryConsentState.NotApplicable, + TelemetryConsentState.NotApplicable, + TelemetryConsentStatusReason.NotApplicable, + TelemetryPolicyState.NotApplicable); + } + + try + { + unsafe + { + byte* outUtf8 = null; + try + { + EnsureSuccess( + NativeMethods.mxc_telemetry_get_consent_status(&outUtf8), + "failed to read telemetry consent status"); + return ParseConsentStatus(Marshal.PtrToStringUTF8((IntPtr)outUtf8)); + } + finally + { + if (outUtf8 is not null) + { + NativeMethods.mxc_string_free(outUtf8); + } + } + } + } + catch (Exception ex) + { + ReportFailClosed("GetConsentStatus", "Undetermined/Blocked", ex); + return new( + TelemetryConsentState.Undetermined, + TelemetryConsentState.Undetermined, + TelemetryConsentStatusReason.StoreUnreadable, + TelemetryPolicyState.Blocked); + } + } + + /// Idempotently withdraw telemetry consent. + public static TelemetryConsentOutcome WithdrawConsent() + { + try + { + unsafe + { + byte* outUtf8 = null; + try + { + EnsureSuccess( + NativeMethods.mxc_telemetry_withdraw_consent(&outUtf8), + "failed to withdraw telemetry consent"); + return ParseConsentOutcome(Marshal.PtrToStringUTF8((IntPtr)outUtf8)); + } + finally + { + if (outUtf8 is not null) + { + NativeMethods.mxc_string_free(outUtf8); + } + } + } + } + catch (Exception ex) when (ex is not MxcException) + { + throw new MxcException( + ErrorCode.ConsentWriteFailed, + "failed to withdraw telemetry consent", + ex); + } + } + + private static TelemetryConsentOutcome RequestConsentCore( + Func> presenter, + string? locale) + { + var context = new PresenterContext { Presenter = presenter }; + var handle = GCHandle.Alloc(context); + var localeBuffer = locale is null ? null : ToNullTerminatedUtf8(locale); + try + { + unsafe + { + fixed (byte* localePtr = localeBuffer) + { + byte* outUtf8 = null; + var status = NativeMethods.mxc_telemetry_request_consent( + localePtr, + &PresentConsent, + (void*)GCHandle.ToIntPtr(handle), + &outUtf8); + try + { + if (context.Error is not null) + { + throw new MxcException( + ErrorCode.BackendError, + "telemetry consent presenter failed", + context.Error); + } + EnsureSuccess(status, "failed to request telemetry consent"); + return ParseConsentOutcome(Marshal.PtrToStringUTF8((IntPtr)outUtf8)); + } + finally + { + if (outUtf8 is not null) + { + NativeMethods.mxc_string_free(outUtf8); + } + } + } + } + } + catch (Exception ex) when (ex is not MxcException) + { + throw new MxcException( + ErrorCode.ConsentWriteFailed, + "telemetry consent request failed", + ex); + } + finally + { + handle.Free(); + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static unsafe int PresentConsent(byte* promptJsonUtf8, void* contextPointer) + { + var context = (PresenterContext)GCHandle.FromIntPtr((IntPtr)contextPointer).Target!; + try + { + var prompt = ParseConsentPrompt(Marshal.PtrToStringUTF8((IntPtr)promptJsonUtf8)); + return context.Presenter(prompt).AsTask().GetAwaiter().GetResult() switch + { + TelemetryConsentDecision.No => 0, + TelemetryConsentDecision.Yes => 1, + TelemetryConsentDecision.Dismissed => 2, + _ => -1, + }; + } + catch (Exception ex) + { + context.Error = ex; + return -1; + } + } + + private static void EnsureSuccess(int status, string message) + { + if (status != (int)ErrorCode.Success) + { + throw new MxcException((ErrorCode)status, message); + } + } + + private static TelemetryConsentPrompt ParseConsentPrompt(string? json) + { + using var document = JsonDocument.Parse(json ?? throw new JsonException("missing consent prompt")); + var root = document.RootElement; + return new( + root.GetProperty("resourceVersion").GetUInt32(), + root.GetProperty("locale").GetString() ?? throw new JsonException("missing locale"), + ParseConsentMessage(root.GetProperty("title")), + ParseConsentMessage(root.GetProperty("body")), + ParseConsentMessage(root.GetProperty("affirmativeLabel")), + ParseConsentMessage(root.GetProperty("negativeLabel")), + ParseConsentMessage(root.GetProperty("learnMoreLabel")), + root.GetProperty("learnMoreUrl").GetString() ?? throw new JsonException("missing learn-more URL")); + } + + private static TelemetryConsentMessage ParseConsentMessage(JsonElement value) => + new( + value.GetProperty("id").GetString() ?? throw new JsonException("missing message id"), + value.GetProperty("text").GetString() ?? throw new JsonException("missing message text")); + + private static TelemetryConsentStatus ParseConsentStatus(string? json) + { + using var document = JsonDocument.Parse(json ?? throw new JsonException("missing consent status")); + var root = document.RootElement; + return new( + ParseConsentState(root.GetProperty("storedState").GetString()), + ParseConsentState(root.GetProperty("effectiveState").GetString()), + ParseConsentStatusReason(root.GetProperty("reason")), + ParsePolicyState(root.GetProperty("policy").GetString())); + } + + private static TelemetryConsentOutcome ParseConsentOutcome(string? json) + { + using var document = JsonDocument.Parse(json ?? throw new JsonException("missing consent outcome")); + var root = document.RootElement; + return new( + ParseConsentActionResult(root.GetProperty("result").GetString()), + ParseConsentState(root.GetProperty("storedState").GetString()), + ParseConsentState(root.GetProperty("effectiveState").GetString()), + ParseConsentStatusReason(root.GetProperty("reason")), + ParsePolicyState(root.GetProperty("policy").GetString())); + } + + private static TelemetryConsentActionResult ParseConsentActionResult(string? value) => value switch + { + "granted" => TelemetryConsentActionResult.Granted, + "denied" => TelemetryConsentActionResult.Denied, + "dismissed" => TelemetryConsentActionResult.Dismissed, + "withdrawn" => TelemetryConsentActionResult.Withdrawn, + "already-granted" => TelemetryConsentActionResult.AlreadyGranted, + "policy-blocked" => TelemetryConsentActionResult.PolicyBlocked, + "not-applicable" => TelemetryConsentActionResult.NotApplicable, + _ => throw new JsonException($"unrecognized consent action result '{value ?? ""}'"), + }; + + private static TelemetryConsentStatusReason? ParseConsentStatusReason(JsonElement value) + { + if (value.ValueKind == JsonValueKind.Null) + { + return null; + } + + return value.GetString() switch + { + "no-record" => TelemetryConsentStatusReason.NoRecord, + "store-unreadable" => TelemetryConsentStatusReason.StoreUnreadable, + "store-malformed" => TelemetryConsentStatusReason.StoreMalformed, + "consent-schema-unsupported" => TelemetryConsentStatusReason.ConsentSchemaUnsupported, + "prompt-version-missing" => TelemetryConsentStatusReason.PromptVersionMissing, + "prompt-version-unsupported" => TelemetryConsentStatusReason.PromptVersionUnsupported, + "not-applicable" => TelemetryConsentStatusReason.NotApplicable, + var reason => throw new JsonException($"unrecognized consent status reason '{reason ?? ""}'"), + }; + } + + /// + /// 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..7ad5fdbc8 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) @@ -62,15 +100,38 @@ 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. + // Dev layout: walk up looking for the Cargo target dir. Probe the + // 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 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 } + + // 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/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/Microsoft.Mxc.Sdk/TelemetryConsent.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/TelemetryConsent.cs new file mode 100644 index 000000000..1c2ae0b35 --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/TelemetryConsent.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Mxc.Sdk; + +/// An independently localizable canonical consent message. +public sealed record TelemetryConsentMessage(string Id, string Text); + +/// The complete Rust-owned consent resource a host must render verbatim. +public sealed record TelemetryConsentPrompt( + uint ResourceVersion, + string Locale, + TelemetryConsentMessage Title, + TelemetryConsentMessage Body, + TelemetryConsentMessage AffirmativeLabel, + TelemetryConsentMessage NegativeLabel, + TelemetryConsentMessage LearnMoreLabel, + string LearnMoreUrl); + +/// The explicit result returned by a host consent presenter. +public enum TelemetryConsentDecision +{ + No, + Yes, + Dismissed, +} + +/// Why persisted consent does not currently authorize collection. +public enum TelemetryConsentStatusReason +{ + NoRecord, + StoreUnreadable, + StoreMalformed, + ConsentSchemaUnsupported, + PromptVersionMissing, + PromptVersionUnsupported, + NotApplicable, +} + +/// Result of a consent request or withdrawal. +public enum TelemetryConsentActionResult +{ + Granted, + Denied, + Dismissed, + Withdrawn, + AlreadyGranted, + PolicyBlocked, + NotApplicable, +} + +/// Persisted and effective consent together with the policy ceiling. +public sealed record TelemetryConsentStatus( + TelemetryConsentState StoredState, + TelemetryConsentState EffectiveState, + TelemetryConsentStatusReason? Reason, + TelemetryPolicyState Policy); + +/// Result and resulting status of a consent-changing operation. +public sealed record TelemetryConsentOutcome( + TelemetryConsentActionResult Result, + TelemetryConsentState StoredState, + TelemetryConsentState EffectiveState, + TelemetryConsentStatusReason? Reason, + TelemetryPolicyState Policy); diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/TelemetryConsentState.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/TelemetryConsentState.cs new file mode 100644 index 000000000..b691bdaa2 --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/TelemetryConsentState.cs @@ -0,0 +1,37 @@ +// 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 must use MxcTelemetry.NeedsConsentPrompt(), not this state + /// alone, to decide whether presentation is appropriate under the current + /// policy. + /// + 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 e7c8ae11c..4018fc81d 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. @@ -78,6 +78,78 @@ process to release native resources (killing the child if still running). After a terminal wait, `MxcSandboxProcess.OutputMetadata` exposes the same structured feature outputs as `RunResult.OutputMetadata`. +## 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. It invokes a host presenter with the exact +Rust-owned canonical resource; only the typed decision returned from that +invocation may be persisted. Follow the normative +[SDK presenter requirements](../../docs/telemetry/telemetry-consent-design.md#sdk-presenter-requirements) +for control mappings, dismissal behavior, learn-more handling, status, and +withdrawal. + +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; + +var outcome = MxcTelemetry.RequestConsent(prompt => +{ + // Render title, body, labels, and privacy link verbatim. + return TelemetryConsentDecision.Yes; +}); + +TelemetryConsentStatus status = MxcTelemetry.GetConsentStatus(); +MxcTelemetry.WithdrawConsent(); +``` + +`RequestConsentAsync` accepts an asynchronous presenter. If consent is never +requested, the presenter fails, or it returns `Dismissed`, telemetry remains +off. On non-Windows hosts requests and withdrawals return `NotApplicable` +without invoking a presenter. + +### 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). @@ -86,6 +158,11 @@ structured feature outputs as `RunResult.OutputMetadata`. 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: diff --git a/sdk/node/README.md b/sdk/node/README.md index 47ca684e3..925fd0937 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. @@ -414,6 +414,14 @@ getAvailableToolsPolicy(env?, options?) → FilesystemPolicyResult getUserProfilePolicy() → FilesystemPolicyResult getTemporaryFilesPolicy(env?) → FilesystemPolicyResult +// Telemetry consent (Windows-only; see Telemetry Consent section below) +getTelemetryConsent() → TelemetryConsentState +queryTelemetryConsent() → { storedState, effectiveState, needsPrompt, policy, reason?, error? } +needsTelemetryConsentPrompt() → boolean +requestTelemetryConsent(presenter, locale?) → Promise +withdrawTelemetryConsent() → TelemetryConsentOutcome +getTelemetryPolicy() → TelemetryPolicyState + // Capability types UiCapabilitySupport @@ -428,6 +436,104 @@ 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. + +Telemetry is additionally off per invocation unless a one-shot +`ContainerConfig` includes `telemetry: { enabled: true }`, a one-shot call +passes `options.telemetry: { enabled: true }`, or a state-aware call passes the +same option. For `spawnSandboxFromConfig`, an explicitly supplied option +overrides `config.telemetry`; omitting it preserves the config value. This +stable switch does not require `options.experimental` and cannot bypass consent +or administrative policy. + +The SDK does not ship a consent UI. It invokes your presenter with the exact +Rust-owned canonical resource over a private, session-bound child protocol. +Render every supplied field verbatim and return a typed decision. Follow the +normative +[SDK presenter requirements](https://github.com/microsoft/mxc/blob/main/docs/telemetry/telemetry-consent-design.md#sdk-presenter-requirements) +for control mappings, dismissal behavior, learn-more handling, status, and +withdrawal: + +```typescript +import { + requestTelemetryConsent, + queryTelemetryConsent, + withdrawTelemetryConsent, +} from '@microsoft/mxc-sdk'; + +const outcome = await requestTelemetryConsent(async (prompt) => { + // Render title, body, labels, and privacy link verbatim. + return 'yes'; // 'yes' | 'no' | 'dismissed' +}, 'en-US'); + +const status = queryTelemetryConsent(); +withdrawTelemetryConsent(); +``` + +If the API is never called, the presenter fails, or it returns `dismissed`, +telemetry remains off. On non-Windows hosts requests and withdrawals return +`notApplicable` without invoking the presenter or child process. + +`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 { effectiveState, storedState, needsPrompt, policy, reason, 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 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/telemetry-consent-wire.ts b/sdk/node/src/generated/telemetry-consent-wire.ts new file mode 100644 index 000000000..6c71c9268 --- /dev/null +++ b/sdk/node/src/generated/telemetry-consent-wire.ts @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/* eslint-disable */ +/** + * GENERATED FILE — DO NOT EDIT BY HAND. + * + * Emitted from the generated JSON Schema (itself generated from the Rust wire + * model `wxc_common::wire`) by the `mxc_schema_gen --ts` TypeScript emitter + * (`wxc_common::ts_emit`). This is a drift oracle, not public API: it is never + * exported from the SDK. The conformance test asserts the hand-written public + * types in `../types.ts` still match these. CI gate: + * `scripts/versioning/check-sdk-types-codegen.js`. + * + * Regenerate with: + * cargo run --manifest-path src/Cargo.toml -p mxc_schema_gen -- --ts sdk/node/src/generated/wire.ts + */ +/** + * Telemetry-consent maintenance action. + */ +export type TelemetryConsentAction = "request" | "withdraw" | "status"; + +/** + * Fixed telemetry-consent maintenance command discriminator. + */ +export type TelemetryConsentCommand = "telemetryConsent"; + +/** + * Typed decision returned by a consent presenter. + */ +export type TelemetryConsentDecision = "yes" | "no" | "dismissed"; + +/** + * Telemetry-consent maintenance request. + * + * This is a separate contract from [`MxcConfig`], even though executors use the same JSON input loader for both. Its explicit `command` discriminator lets the loader route maintenance without widening the execution schema. + */ +export interface TelemetryConsentMaintenanceRequest { + /** + * Optional JSON Schema reference for editor validation. + */ + $schema?: string | null; + /** + * Consent operation to perform. + */ + action: unknown; + /** + * Fixed maintenance discriminator. Must be `"telemetryConsent"`. + */ + command: unknown; + /** + * Preferred BCP 47 locale for the canonical prompt. Unsupported locales fall back to `en-US`. Used only by `request`. + */ + locale?: string | null; +} + +/** + * Typed telemetry-consent maintenance response. + */ +export interface TelemetryConsentMaintenanceResponse { + action: TelemetryConsentAction; + /** + * Opaque, single-request challenge for the private SDK presenter handshake. Never persisted. + */ + challenge?: string | null; + effectiveState: TelemetryConsentState; + needsPrompt: boolean; + policy: TelemetryConsentPolicyState; + /** + * Present only during the private SDK presenter handshake. + */ + prompt?: TelemetryConsentPrompt | null; + reason?: TelemetryConsentStatusReason | null; + result: TelemetryConsentResult; + storedState: TelemetryConsentState; +} + +/** + * One canonical prompt message. + */ +export interface TelemetryConsentMessage { + id: string; + text: string; +} + +/** + * Stable policy-state strings used by maintenance responses. + */ +export type TelemetryConsentPolicyState = "unrestricted" | "allowed" | "blocked" | "not-applicable"; + +/** + * Private, session-bound presenter response consumed by the same executor process that emitted the challenge. + */ +export interface TelemetryConsentPresenterResponse { + challenge: string; + decision: TelemetryConsentDecision; + resourceVersion: number; +} + +/** + * Canonical prompt data supplied to an SDK presenter. + */ +export interface TelemetryConsentPrompt { + affirmativeLabel: TelemetryConsentMessage; + body: TelemetryConsentMessage; + learnMoreLabel: TelemetryConsentMessage; + learnMoreUrl: string; + locale: string; + negativeLabel: TelemetryConsentMessage; + resourceVersion: number; + title: TelemetryConsentMessage; +} + +/** + * Typed result of a telemetry-consent maintenance action. + */ +export type TelemetryConsentResult = "status" | "presentationRequired" | "granted" | "denied" | "dismissed" | "withdrawn" | "alreadyGranted" | "policyBlocked" | "presentationUnavailable" | "notApplicable"; + +/** + * Stable consent-state strings used by maintenance responses. + */ +export type TelemetryConsentState = "granted" | "denied" | "undetermined" | "not-applicable"; + +/** + * Why persisted consent is not currently effective. + */ +export type TelemetryConsentStatusReason = "no-record" | "store-unreadable" | "store-malformed" | "consent-schema-unsupported" | "prompt-version-missing" | "prompt-version-unsupported" | "policy-blocked" | "presentation-unavailable" | "not-applicable"; + +export interface MXCConfiguration { + presenter_response: TelemetryConsentPresenterResponse; + request: TelemetryConsentMaintenanceRequest; + response: TelemetryConsentMaintenanceResponse; + [k: string]: unknown; +} + diff --git a/sdk/node/src/generated/wire.ts b/sdk/node/src/generated/wire.ts index d6be5a8d4..452967fee 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,14 +339,13 @@ export interface Seatbelt { } /** - * Telemetry configuration (`experimental.telemetry`). + * Telemetry configuration (`telemetry`). */ 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; } /** @@ -471,7 +466,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 +513,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/helper.ts b/sdk/node/src/helper.ts index d0d2fbabe..d2dc4c085 100644 --- a/sdk/node/src/helper.ts +++ b/sdk/node/src/helper.ts @@ -284,7 +284,10 @@ export function resolveExecutableAndArgs( ); } - const resolved = resolveBinaryAndCommonArgs(JSON.stringify(config), options); + const executionConfig = options.telemetry === undefined + ? config + : { ...config, telemetry: options.telemetry }; + const resolved = resolveBinaryAndCommonArgs(JSON.stringify(executionConfig), options); if (usesBuiltinTestServer) { resolved.args.push('--allow-testing-features'); } diff --git a/sdk/node/src/index.ts b/sdk/node/src/index.ts index 4432ba7a0..05c580308 100644 --- a/sdk/node/src/index.ts +++ b/sdk/node/src/index.ts @@ -125,3 +125,21 @@ export { stopSandbox, deprovisionSandbox, } from './state-aware.js'; + +// Export telemetry consent functions and types +export { + TelemetryConsentState, + TelemetryConsentStatusReason, + TelemetryConsentPrompt, + TelemetryConsentDecision, + TelemetryConsentOutcome, + TelemetryConsentPresenter, + TelemetryConsentQuery, + TelemetryPolicyState, + getTelemetryConsent, + queryTelemetryConsent, + needsTelemetryConsentPrompt, + getTelemetryPolicy, + requestTelemetryConsent, + withdrawTelemetryConsent, +} from './telemetry.js'; diff --git a/sdk/node/src/sandbox.ts b/sdk/node/src/sandbox.ts index 350032fd7..f9875dc85 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,15 @@ export interface SandboxSpawnOptions { */ experimental?: boolean; + /** + * Stable per-invocation telemetry configuration for one-shot and state-aware + * calls. On one-shot calls this overrides `ContainerConfig.telemetry` when + * provided. 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/telemetry.ts b/sdk/node/src/telemetry.ts new file mode 100644 index 000000000..387f53fe5 --- /dev/null +++ b/sdk/node/src/telemetry.ts @@ -0,0 +1,371 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { execFileSync, spawn } from 'node:child_process'; +import type { + TelemetryConsentDecision as WireConsentDecision, + TelemetryConsentMaintenanceRequest, + TelemetryConsentMaintenanceResponse, + TelemetryConsentPolicyState, + TelemetryConsentPrompt as WireConsentPrompt, + TelemetryConsentResult, + TelemetryConsentState, + TelemetryConsentStatusReason, +} from './generated/telemetry-consent-wire.js'; +import { findWxcExecutable } from './platform.js'; + +export type { + TelemetryConsentPolicyState as TelemetryPolicyState, + TelemetryConsentState, + TelemetryConsentStatusReason, +}; +export type TelemetryConsentPrompt = WireConsentPrompt; +export type TelemetryConsentDecision = WireConsentDecision; +export type TelemetryConsentOutcome = TelemetryConsentMaintenanceResponse; +export type TelemetryConsentPresenter = ( + prompt: TelemetryConsentPrompt, +) => TelemetryConsentDecision | Promise; + +export interface TelemetryConsentQuery { + state: TelemetryConsentState; + storedState: TelemetryConsentState; + effectiveState: TelemetryConsentState; + needsPrompt: boolean; + policy: TelemetryConsentPolicyState; + reason?: TelemetryConsentStatusReason; + error?: string; +} + +type ConsentRunner = (args: readonly string[]) => string; +type ConsentProtocolRunner = ( + locale: string | undefined, + presenter: TelemetryConsentPresenter, +) => Promise; + +const CONSENT_REQUEST_TIMEOUT_MS = 30_000; + +function maintenanceRequest( + action: 'request' | 'withdraw' | 'status', + locale?: string, +): TelemetryConsentMaintenanceRequest { + return { + command: 'telemetryConsent', + action, + ...(locale === undefined ? {} : { locale }), + }; +} + +function maintenanceArgs(action: 'request' | 'withdraw' | 'status', locale?: string): string[] { + const payload = Buffer.from(JSON.stringify(maintenanceRequest(action, locale)), 'utf8').toString('base64'); + return ['--config-base64', payload]; +} + +function executable(): string { + const path = findWxcExecutable(); + if (!path) { + throw new Error('wxc-exec was not found; the MXC native binary is missing from this installation'); + } + return path; +} + +function defaultConsentRunner(args: readonly string[]): string { + return execFileSync(executable(), args, { + timeout: 5000, + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +async function defaultConsentProtocolRunner( + locale: string | undefined, + presenter: TelemetryConsentPresenter, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(executable(), maintenanceArgs('request', locale), { + env: { ...process.env, MXC_TELEMETRY_CONSENT_PRESENTER_PROTOCOL: '1' }, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + let stdout = ''; + let stderr = ''; + let finalResponse: TelemetryConsentMaintenanceResponse | undefined; + let presenterFailure: unknown; + let settled = false; + const timeout = setTimeout(() => { + settled = true; + child.kill(); + reject(new Error('telemetry consent request timed out')); + }, CONSENT_REQUEST_TIMEOUT_MS); + + const fail = (error: unknown): void => { + if (!settled) { + settled = true; + clearTimeout(timeout); + reject(error instanceof Error ? error : new Error(String(error))); + } + }; + + const processLine = async (line: string): Promise => { + if (line.trim() === '') return; + let response: TelemetryConsentMaintenanceResponse; + try { + response = parseMaintenanceResponse(line); + } catch (error) { + fail(error); + child.kill(); + return; + } + + if (response.result !== 'presentationRequired') { + finalResponse = response; + return; + } + if (!response.prompt || !response.challenge) { + fail(new Error('telemetry consent presentation omitted its prompt or challenge')); + child.kill(); + return; + } + + let decision: TelemetryConsentDecision = 'dismissed'; + try { + decision = await presenter(response.prompt); + if (!isDecision(decision)) { + throw new Error(`consent presenter returned invalid decision '${String(decision)}'`); + } + } catch (error) { + presenterFailure = error; + } + if (settled) { + return; + } + child.stdin.write(`${JSON.stringify({ + challenge: response.challenge, + resourceVersion: response.prompt.resourceVersion, + decision, + })}\n`); + child.stdin.end(); + }; + + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + const lines = stdout.split(/\r?\n/); + stdout = lines.pop() ?? ''; + for (const line of lines) { + void processLine(line); + } + }); + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + child.stdin.on('error', (error) => { + if (!settled) { + fail(error); + } + }); + child.on('error', fail); + child.on('close', (code) => { + if (settled) return; + clearTimeout(timeout); + if (stdout.trim() !== '') { + try { + finalResponse = parseMaintenanceResponse(stdout); + } catch (error) { + fail(error); + return; + } + } + if (presenterFailure !== undefined) { + fail(new Error( + `telemetry consent presenter failed: ${ + presenterFailure instanceof Error ? presenterFailure.message : String(presenterFailure) + }`, + )); + return; + } + if (code !== 0 || finalResponse === undefined) { + fail(new Error(`telemetry consent process failed (${code ?? 'no exit code'}): ${stderr.trim()}`)); + return; + } + settled = true; + resolve(finalResponse); + }); + }); +} + +let consentRunner: ConsentRunner = defaultConsentRunner; +let protocolRunner: ConsentProtocolRunner = defaultConsentProtocolRunner; +let platformOverride: NodeJS.Platform | null = null; + +/** @internal Test-only. */ +export function _setTelemetryConsentRunner(runner: ConsentRunner | null): void { + consentRunner = runner ?? defaultConsentRunner; +} + +/** @internal Test-only. */ +export function _setTelemetryConsentProtocolRunner(runner: ConsentProtocolRunner | null): void { + protocolRunner = runner ?? defaultConsentProtocolRunner; +} + +/** @internal Test-only. */ +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 TelemetryConsentPolicyState { + return value === 'unrestricted' || value === 'allowed' || value === 'blocked' || value === 'not-applicable'; +} + +function isDecision(value: unknown): value is TelemetryConsentDecision { + return value === 'yes' || value === 'no' || value === 'dismissed'; +} + +function isResult(value: unknown): value is TelemetryConsentResult { + return [ + 'status', + 'presentationRequired', + 'granted', + 'denied', + 'dismissed', + 'withdrawn', + 'alreadyGranted', + 'policyBlocked', + 'presentationUnavailable', + 'notApplicable', + ].includes(value as string); +} + +function parseMaintenanceResponse(stdout: string): TelemetryConsentMaintenanceResponse { + const parsed: unknown = JSON.parse(stdout); + if (parsed === null || typeof parsed !== 'object') { + throw new Error('unrecognised telemetry consent output'); + } + const value = parsed as Record; + if ( + !isConsentState(value.storedState) + || !isConsentState(value.effectiveState) + || !isPolicyState(value.policy) + || !isResult(value.result) + || typeof value.needsPrompt !== 'boolean' + ) { + throw new Error(`unrecognised telemetry consent output: ${stdout.trim().slice(0, 200)}`); + } + return parsed as TelemetryConsentMaintenanceResponse; +} + +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)) { + reportedFailures.add(message); + console.warn(message); + } + } catch { + // Diagnostics must never break a fail-closed status query. + } +} + +/** @internal Test-only. */ +export function _resetTelemetryFailureReporting(): void { + reportedFailures.clear(); +} + +function notApplicable(action: 'request' | 'withdraw' | 'status'): TelemetryConsentMaintenanceResponse { + return { + action, + result: 'notApplicable', + storedState: 'not-applicable', + effectiveState: 'not-applicable', + needsPrompt: false, + policy: 'not-applicable', + reason: 'not-applicable', + }; +} + +/** Read persisted/effective consent and policy. Never throws. */ +export function queryTelemetryConsent(): TelemetryConsentQuery { + if (!isWindows()) { + return { + state: 'not-applicable', + storedState: 'not-applicable', + effectiveState: 'not-applicable', + needsPrompt: false, + policy: 'not-applicable', + reason: 'not-applicable', + }; + } + try { + const response = parseMaintenanceResponse(consentRunner(maintenanceArgs('status'))); + return { + state: response.effectiveState, + storedState: response.storedState, + effectiveState: response.effectiveState, + needsPrompt: response.needsPrompt && response.policy !== 'blocked', + policy: response.policy, + ...(response.reason === undefined || response.reason === null ? {} : { reason: response.reason }), + }; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + reportFailClosed('queryTelemetryConsent', 'undetermined', detail); + return { + state: 'undetermined', + storedState: 'undetermined', + effectiveState: 'undetermined', + needsPrompt: false, + policy: 'blocked', + reason: 'store-unreadable', + error: `failed to read telemetry consent: ${detail}`, + }; + } +} + +export function getTelemetryConsent(): TelemetryConsentState { + return queryTelemetryConsent().effectiveState; +} + +export function needsTelemetryConsentPrompt(): boolean { + return queryTelemetryConsent().needsPrompt; +} + +export function getTelemetryPolicy(): TelemetryConsentPolicyState { + return queryTelemetryConsent().policy; +} + +/** + * Request consent through the private, session-bound executor protocol. + * The presenter must render every supplied prompt field verbatim. + */ +export async function requestTelemetryConsent( + presenter: TelemetryConsentPresenter, + locale?: string, +): Promise { + if (!isWindows()) { + return notApplicable('request'); + } + return protocolRunner(locale, presenter); +} + +/** Idempotently withdraw telemetry consent. */ +export function withdrawTelemetryConsent(): TelemetryConsentOutcome { + if (!isWindows()) { + return notApplicable('withdraw'); + } + try { + return parseMaintenanceResponse(consentRunner(maintenanceArgs('withdraw'))); + } catch (error) { + throw new Error( + `failed to withdraw telemetry consent: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} 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/sandbox.test.ts b/sdk/node/tests/unit/sandbox.test.ts index 054346cba..456bfde77 100644 --- a/sdk/node/tests/unit/sandbox.test.ts +++ b/sdk/node/tests/unit/sandbox.test.ts @@ -1197,4 +1197,50 @@ describe('resolveExecutableAndArgs (containment validation)', { skip: platformSk ); }); }); + + describe('one-shot telemetry options', () => { + function decodeConfig(args: string[]): ContainerConfig { + const index = args.indexOf('--config-base64'); + assert.ok(index >= 0, '--config-base64 should be present in args'); + return JSON.parse( + Buffer.from(args[index + 1], 'base64').toString('utf-8'), + ) as ContainerConfig; + } + + it('copies options.telemetry into the one-shot execution config', () => { + const config = makeConfig('process'); + const { args } = resolveExecutableAndArgs(config, { + executablePath: fakeExe, + skipPlatformCheck: true, + telemetry: { enabled: true }, + }); + + assert.deepStrictEqual(decodeConfig(args).telemetry, { enabled: true }); + assert.strictEqual(config.telemetry, undefined, 'caller config must not be mutated'); + }); + + it('preserves config.telemetry when the option is omitted', () => { + const config = makeConfig('process'); + config.telemetry = { enabled: true }; + const { args } = resolveExecutableAndArgs(config, { + executablePath: fakeExe, + skipPlatformCheck: true, + }); + + assert.deepStrictEqual(decodeConfig(args).telemetry, { enabled: true }); + }); + + it('lets the per-invocation option override config.telemetry', () => { + const config = makeConfig('process'); + config.telemetry = { enabled: false }; + const { args } = resolveExecutableAndArgs(config, { + executablePath: fakeExe, + skipPlatformCheck: true, + telemetry: { enabled: true }, + }); + + assert.deepStrictEqual(decodeConfig(args).telemetry, { enabled: true }); + assert.deepStrictEqual(config.telemetry, { enabled: false }); + }); + }); }); 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/telemetry.test.ts b/sdk/node/tests/unit/telemetry.test.ts new file mode 100644 index 000000000..2e23164bc --- /dev/null +++ b/sdk/node/tests/unit/telemetry.test.ts @@ -0,0 +1,192 @@ +// 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, + requestTelemetryConsent, + withdrawTelemetryConsent, + _setTelemetryConsentRunner, + _setTelemetryConsentProtocolRunner, + _setTelemetryPlatform, + _resetTelemetryFailureReporting, + type TelemetryConsentPrompt, +} from '../../src/telemetry.js'; + +const prompt: TelemetryConsentPrompt = { + resourceVersion: 1, + locale: 'en-US', + title: { id: 'telemetry.consent.title', text: 'Help improve Microsoft eXecution Container (MXC)' }, + body: { id: 'telemetry.consent.body', text: 'canonical body' }, + affirmativeLabel: { id: 'telemetry.consent.yes', text: 'Yes' }, + negativeLabel: { id: 'telemetry.consent.no', text: 'No' }, + learnMoreLabel: { id: 'telemetry.consent.learnMore', text: 'Privacy Statement' }, + learnMoreUrl: 'https://go.microsoft.com/fwlink/?linkid=521839', +}; + +function status( + effectiveState: 'granted' | 'denied' | 'undetermined' | 'not-applicable', + policy: 'unrestricted' | 'allowed' | 'blocked' | 'not-applicable' = 'unrestricted', + needsPrompt = false, +): string { + return JSON.stringify({ + action: 'status', + result: 'status', + storedState: effectiveState, + effectiveState, + needsPrompt, + policy, + }); +} + +describe('telemetry consent', () => { + beforeEach(() => { + _setTelemetryPlatform('win32'); + }); + + afterEach(() => { + _setTelemetryConsentRunner(null); + _setTelemetryConsentProtocolRunner(null); + _setTelemetryPlatform(null); + }); + + it('parses typed stored/effective status', () => { + _setTelemetryConsentRunner(() => status('granted', 'allowed')); + assert.deepStrictEqual(queryTelemetryConsent(), { + state: 'granted', + storedState: 'granted', + effectiveState: 'granted', + needsPrompt: false, + policy: 'allowed', + }); + assert.strictEqual(getTelemetryConsent(), 'granted'); + assert.strictEqual(getTelemetryPolicy(), 'allowed'); + }); + + it('passes status through the typed JSON maintenance envelope', () => { + let args: readonly string[] = []; + _setTelemetryConsentRunner((value) => { + args = value; + return status('undetermined', 'unrestricted', true); + }); + assert.strictEqual(needsTelemetryConsentPrompt(), true); + assert.strictEqual(args[0], '--config-base64'); + const request = JSON.parse(Buffer.from(args[1]!, 'base64').toString('utf8')); + assert.deepStrictEqual(request, { command: 'telemetryConsent', action: 'status' }); + }); + + it('fails closed on malformed output and reports the failure once', () => { + _resetTelemetryFailureReporting(); + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => warnings.push(args.map(String).join(' ')); + try { + _setTelemetryConsentRunner(() => 'not json'); + assert.strictEqual(getTelemetryConsent(), 'undetermined'); + assert.strictEqual(getTelemetryPolicy(), 'blocked'); + assert.strictEqual(needsTelemetryConsentPrompt(), false); + } finally { + console.warn = originalWarn; + } + assert.strictEqual(warnings.length, 1); + assert.match(warnings[0]!, /fail-closed/); + }); + + it('never reports a prompt alongside blocked policy', () => { + _setTelemetryConsentRunner(() => status('undetermined', 'blocked', true)); + assert.strictEqual(needsTelemetryConsentPrompt(), false); + }); + + it('binds a synchronous presenter decision to the canonical prompt', async () => { + let observedLocale: string | undefined; + _setTelemetryConsentProtocolRunner(async (locale, presenter) => { + observedLocale = locale; + const decision = await presenter(prompt); + assert.strictEqual(decision, 'yes'); + return { + action: 'request', + result: 'granted', + storedState: 'granted', + effectiveState: 'granted', + needsPrompt: false, + policy: 'unrestricted', + }; + }); + + const outcome = await requestTelemetryConsent((value) => { + assert.deepStrictEqual(value, prompt); + return 'yes'; + }, 'en-US'); + assert.strictEqual(observedLocale, 'en-US'); + assert.strictEqual(outcome.result, 'granted'); + }); + + it('supports an asynchronous presenter and propagates presenter failure', async () => { + _setTelemetryConsentProtocolRunner(async (_locale, presenter) => { + await presenter(prompt); + throw new Error('should not continue'); + }); + await assert.rejects( + requestTelemetryConsent(async () => { + await Promise.resolve(); + throw new Error('UI unavailable'); + }), + /UI unavailable/, + ); + }); + + it('withdraws through the typed JSON maintenance envelope', () => { + let args: readonly string[] = []; + _setTelemetryConsentRunner((value) => { + args = value; + return JSON.stringify({ + action: 'withdraw', + result: 'withdrawn', + storedState: 'denied', + effectiveState: 'denied', + needsPrompt: false, + policy: 'blocked', + }); + }); + const outcome = withdrawTelemetryConsent(); + assert.strictEqual(outcome.result, 'withdrawn'); + const request = JSON.parse(Buffer.from(args[1]!, 'base64').toString('utf8')); + assert.deepStrictEqual(request, { command: 'telemetryConsent', action: 'withdraw' }); + }); +}); + +describe('telemetry consent is Windows-only', () => { + afterEach(() => { + _setTelemetryConsentRunner(null); + _setTelemetryConsentProtocolRunner(null); + _setTelemetryPlatform(null); + }); + + for (const platform of ['linux', 'darwin'] as const) { + it(`does not query or present consent on ${platform}`, async () => { + _setTelemetryPlatform(platform); + let called = false; + _setTelemetryConsentRunner(() => { + called = true; + throw new Error('must not run'); + }); + _setTelemetryConsentProtocolRunner(async () => { + called = true; + throw new Error('must not run'); + }); + + assert.strictEqual(getTelemetryConsent(), 'not-applicable'); + const request = await requestTelemetryConsent(() => { + called = true; + return 'yes'; + }); + assert.strictEqual(request.result, 'notApplicable'); + assert.strictEqual(withdrawTelemetryConsent().result, 'notApplicable'); + assert.strictEqual(called, false); + }); + } +}); 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/Cargo.lock b/src/Cargo.lock index 7b934329f..aadf56a33 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -1537,6 +1537,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..07ed66815 100644 --- a/src/core/lxc/src/main.rs +++ b/src/core/lxc/src/main.rs @@ -73,6 +73,69 @@ 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, + + /// Legacy non-mutating tombstone; directs callers to maintenance JSON. + #[arg(long = "telemetry-consent-grant")] + telemetry_consent_grant: bool, + + /// Legacy non-mutating tombstone; directs callers to maintenance JSON. + #[arg(long = "telemetry-consent-revoke")] + telemetry_consent_revoke: bool, + + /// Legacy companion tombstone for the removed grant/revoke flow. + #[arg(long = "telemetry-consent-source", allow_hyphen_values = true)] + 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`; legacy state-changing flags return the same non-mutating +/// maintenance-JSON migration error as every other executor. +/// 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 handle_telemetry_consent_input(cli: &Cli) -> bool { + let (input, is_base64) = if let Some(input) = cli.config_base64.as_ref() { + (input, true) + } else if let Some(input) = cli.config.as_ref().or(cli.config_path.as_ref()) { + (input, false) + } else { + return false; + }; + let Some(outcome) = telemetry::consent_cli::handle_maintenance_input(input, is_base64) 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 +176,22 @@ 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; + } + if handle_telemetry_consent_input(&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 +202,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. @@ -207,17 +284,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 @@ -260,7 +332,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 3e7bfa116..5f6e1db28 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)`. @@ -304,6 +308,85 @@ stdin (`Sandbox::take_stdin()` returns `None`), and its process has no host process id (`Sandbox::id()` is `0`) — `kill()` stops the whole container. [`platform_support`] reports `"wslc"` only on a host that can actually run it. +## 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. A host may call +`request_consent()` or `request_consent_async()` and render every field of the +canonical prompt supplied to its presenter callback verbatim. MXC persists a +grant only from the typed decision returned by that callback. If the host never +requests consent, telemetry remains off. See the normative +[SDK presenter requirements](../../../docs/telemetry/telemetry-consent-design.md#sdk-presenter-requirements) +for control mappings, dismissal behavior, learn-more handling, status, and +withdrawal. + +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. The same configured `SandboxRequest` can be passed to either +run-to-completion (`run`) or streaming (`spawn_sandbox`). + +```rust,no_run +use mxc_sdk::telemetry; + +let outcome = telemetry::request_consent(Some("en-US"), |prompt| { + // Render title, body, labels, and privacy link verbatim in host UI. + assert_eq!(prompt.resource_version, 1); + Ok(telemetry::ConsentDecision::Yes) +})?; + +let status = telemetry::get_consent_status(); +let withdrawal = telemetry::withdraw_consent()?; +# let _ = (outcome, status, withdrawal); +# Ok::<(), Box>(()) +``` + +Off Windows `get_consent()` always returns `ConsentState::NotApplicable`, +`needs_consent_prompt()` is always `false`, and consent requests return +`ConsentActionResult::NotApplicable` — 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 86822adde..694dcffdf 100644 --- a/src/core/mxc-sdk/src/lib.rs +++ b/src/core/mxc-sdk/src/lib.rs @@ -111,6 +111,8 @@ mod sandbox; +pub mod telemetry; + pub use mxc_engine::policy; pub use mxc_engine::{ available_backends, available_tools_policy, build_request, build_request_with_containment, diff --git a/src/core/mxc-sdk/src/telemetry.rs b/src/core/mxc-sdk/src/telemetry.rs new file mode 100644 index 000000000..237d89f79 --- /dev/null +++ b/src/core/mxc-sdk/src/telemetry.rs @@ -0,0 +1,568 @@ +// 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 optionally calls [`request_consent`] or +//! [`request_consent_async`]; MXC supplies the mandatory canonical resource to +//! the host presenter and persists only the typed decision returned from that +//! invocation. [`withdraw_consent`] remains available independently. +//! +//! ```no_run +//! use mxc_sdk::telemetry::{self, ConsentDecision, ConsentState}; +//! +//! let outcome = telemetry::request_consent(Some("en-US"), |prompt| { +//! // Render every prompt field verbatim in the host's UI. +//! assert_eq!(prompt.locale, "en-US"); +//! Ok(ConsentDecision::Yes) +//! })?; +//! +//! // Anywhere later, e.g. a settings toggle: +//! match telemetry::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 => {} +//! } +//! # let _ = outcome; +//! # Ok::<(), telemetry::ConsentError>(()) +//! ``` +//! +//! Off Windows, [`get_consent`] always returns +//! [`ConsentState::NotApplicable`] without touching disk, +//! [`needs_consent_prompt`] is always `false`, and consent requests return +//! [`ConsentActionResult::NotApplicable`]. +//! +//! # 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 std::fmt; + +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, + } + } +} + +/// One independently localizable canonical consent message. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConsentMessage { + pub id: &'static str, + pub text: &'static str, +} + +/// Canonical consent resource supplied to a host presenter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConsentPrompt { + pub resource_version: u32, + pub locale: &'static str, + pub title: ConsentMessage, + pub body: ConsentMessage, + pub affirmative_label: ConsentMessage, + pub negative_label: ConsentMessage, + pub learn_more_label: ConsentMessage, + pub learn_more_url: &'static str, +} + +impl From<&wxc_common::telemetry::consent_prompt::ConsentPrompt> for ConsentPrompt { + fn from(value: &wxc_common::telemetry::consent_prompt::ConsentPrompt) -> Self { + fn message(value: wxc_common::telemetry::consent_prompt::ConsentMessage) -> ConsentMessage { + ConsentMessage { + id: value.id, + text: value.text, + } + } + + Self { + resource_version: value.resource_version, + locale: value.locale, + title: message(value.title), + body: message(value.body), + affirmative_label: message(value.affirmative_label), + negative_label: message(value.negative_label), + learn_more_label: message(value.learn_more_label), + learn_more_url: value.learn_more_url, + } + } +} + +/// Explicit result returned by a host consent presenter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConsentDecision { + Yes, + No, + Dismissed, +} + +impl ConsentDecision { + fn to_inner(self) -> inner_consent::ConsentDecision { + match self { + Self::Yes => inner_consent::ConsentDecision::Yes, + Self::No => inner_consent::ConsentDecision::No, + Self::Dismissed => inner_consent::ConsentDecision::Dismissed, + } + } +} + +/// Why stored consent is not currently effective. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConsentStatusReason { + NoRecord, + StoreUnreadable, + StoreMalformed, + ConsentSchemaUnsupported, + PromptVersionMissing, + PromptVersionUnsupported, + NotApplicable, +} + +impl ConsentStatusReason { + /// Stable wire string used by the maintenance and binding contracts. + pub fn as_str(&self) -> &'static str { + match self { + Self::NoRecord => "no-record", + Self::StoreUnreadable => "store-unreadable", + Self::StoreMalformed => "store-malformed", + Self::ConsentSchemaUnsupported => "consent-schema-unsupported", + Self::PromptVersionMissing => "prompt-version-missing", + Self::PromptVersionUnsupported => "prompt-version-unsupported", + Self::NotApplicable => "not-applicable", + } + } +} + +impl From for ConsentStatusReason { + fn from(value: inner_consent::ConsentStatusReason) -> Self { + match value { + inner_consent::ConsentStatusReason::NoRecord => Self::NoRecord, + inner_consent::ConsentStatusReason::StoreUnreadable => Self::StoreUnreadable, + inner_consent::ConsentStatusReason::StoreMalformed => Self::StoreMalformed, + inner_consent::ConsentStatusReason::ConsentSchemaUnsupported => { + Self::ConsentSchemaUnsupported + } + inner_consent::ConsentStatusReason::PromptVersionMissing => Self::PromptVersionMissing, + inner_consent::ConsentStatusReason::PromptVersionUnsupported => { + Self::PromptVersionUnsupported + } + inner_consent::ConsentStatusReason::NotApplicable => Self::NotApplicable, + } + } +} + +/// Stored and effective consent returned by read-only status APIs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConsentStatus { + pub stored_state: ConsentState, + pub effective_state: ConsentState, + pub reason: Option, +} + +impl From for ConsentStatus { + fn from(value: inner_consent::ConsentStatus) -> Self { + Self { + stored_state: value.stored_state.into(), + effective_state: value.effective_state.into(), + reason: value.reason.map(Into::into), + } + } +} + +/// Result of a presenter request or withdrawal. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConsentActionResult { + Granted, + Denied, + Dismissed, + Withdrawn, + AlreadyGranted, + PolicyBlocked, + NotApplicable, +} + +impl ConsentActionResult { + /// Stable wire string used by the maintenance and binding contracts. + pub fn as_str(&self) -> &'static str { + match self { + Self::Granted => "granted", + Self::Denied => "denied", + Self::Dismissed => "dismissed", + Self::Withdrawn => "withdrawn", + Self::AlreadyGranted => "already-granted", + Self::PolicyBlocked => "policy-blocked", + Self::NotApplicable => "not-applicable", + } + } +} + +impl From for ConsentActionResult { + fn from(value: inner_consent::ConsentActionResult) -> Self { + match value { + inner_consent::ConsentActionResult::Granted => Self::Granted, + inner_consent::ConsentActionResult::Denied => Self::Denied, + inner_consent::ConsentActionResult::Dismissed => Self::Dismissed, + inner_consent::ConsentActionResult::Withdrawn => Self::Withdrawn, + inner_consent::ConsentActionResult::AlreadyGranted => Self::AlreadyGranted, + inner_consent::ConsentActionResult::PolicyBlocked => Self::PolicyBlocked, + inner_consent::ConsentActionResult::NotApplicable => Self::NotApplicable, + } + } +} + +/// Consent action result with resulting status and policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConsentActionOutcome { + pub result: ConsentActionResult, + pub status: ConsentStatus, + pub policy: PolicyState, +} + +impl From for ConsentActionOutcome { + fn from(value: inner_consent::ConsentActionOutcome) -> Self { + Self { + result: value.result.into(), + status: value.status.into(), + policy: value.policy.into(), + } + } +} + +/// Failure to present or persist telemetry consent. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConsentError { + Presenter(String), + Persist(String), +} + +impl fmt::Display for ConsentError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Presenter(message) => write!(formatter, "consent presenter failed: {message}"), + Self::Persist(message) => write!(formatter, "failed to persist consent: {message}"), + } + } +} + +impl std::error::Error for ConsentError {} + +impl From for ConsentError { + fn from(value: inner_consent::ConsentActionError) -> Self { + match value { + inner_consent::ConsentActionError::Presenter(message) => Self::Presenter(message), + inner_consent::ConsentActionError::Persist(message) => Self::Persist(message), + } + } +} + +/// 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() +} + +/// Read stored and effective consent with a typed fail-closed reason. +pub fn get_consent_status() -> ConsentStatus { + inner_consent::get_status().into() +} + +/// Invoke a host presenter with the canonical resource and persist its typed +/// decision. +pub fn request_consent( + locale: Option<&str>, + presenter: F, +) -> Result +where + F: FnOnce(&ConsentPrompt) -> Result, +{ + inner_consent::request_consent(locale, |prompt| { + presenter(&ConsentPrompt::from(prompt)).map(ConsentDecision::to_inner) + }) + .map(Into::into) + .map_err(Into::into) +} + +/// Asynchronous counterpart to [`request_consent`]. +pub async fn request_consent_async( + locale: Option<&str>, + presenter: F, +) -> Result +where + F: FnOnce(ConsentPrompt) -> Fut, + Fut: std::future::Future>, +{ + inner_consent::request_consent_async(locale, |prompt| { + let prompt = ConsentPrompt::from(prompt); + async move { presenter(prompt).await.map(ConsentDecision::to_inner) } + }) + .await + .map(Into::into) + .map_err(Into::into) +} + +/// Idempotently withdraw telemetry consent. +pub fn withdraw_consent() -> Result { + inner_consent::withdraw_consent() + .map(Into::into) + .map_err(Into::into) +} + +/// 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"); + } + } + + #[test] + fn canonical_prompt_facade_preserves_every_rust_owned_field() { + let inner = wxc_common::telemetry::consent_prompt::prompt_for_locale(Some("en-US")); + let facade = ConsentPrompt::from(inner); + + assert_eq!(facade.resource_version, inner.resource_version); + assert_eq!(facade.locale, inner.locale); + assert_eq!(facade.title.id, inner.title.id); + assert_eq!(facade.title.text, inner.title.text); + assert_eq!(facade.body.id, inner.body.id); + assert_eq!(facade.body.text, inner.body.text); + assert_eq!(facade.affirmative_label.text, inner.affirmative_label.text); + assert_eq!(facade.negative_label.text, inner.negative_label.text); + assert_eq!(facade.learn_more_label.text, inner.learn_more_label.text); + assert_eq!(facade.learn_more_url, inner.learn_more_url); + } + + #[test] + fn consent_result_wire_strings_are_closed_and_stable() { + assert_eq!(ConsentActionResult::Granted.as_str(), "granted"); + assert_eq!( + ConsentActionResult::AlreadyGranted.as_str(), + "already-granted" + ); + assert_eq!( + ConsentActionResult::PolicyBlocked.as_str(), + "policy-blocked" + ); + assert_eq!( + ConsentStatusReason::PromptVersionUnsupported.as_str(), + "prompt-version-unsupported" + ); + } +} diff --git a/src/core/mxc_darwin/src/main.rs b/src/core/mxc_darwin/src/main.rs index e5f466500..c02f79956 100644 --- a/src/core/mxc_darwin/src/main.rs +++ b/src/core/mxc_darwin/src/main.rs @@ -61,6 +61,72 @@ 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, + + /// Legacy non-mutating tombstone; directs callers to maintenance JSON. + #[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, + + /// Legacy companion tombstone for the removed grant/revoke flow. + #[arg(long = "telemetry-consent-source", allow_hyphen_values = true)] + 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`; legacy state-changing flags return the same non-mutating +/// maintenance-JSON migration error as every other executor. +/// 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 handle_telemetry_consent_input(cli: &Cli) -> bool { + let (input, is_base64) = if let Some(input) = cli.config_base64.as_ref() { + (input, true) + } else if let Some(input) = cli.config.as_ref().or(cli.config_path.as_ref()) { + (input, false) + } else { + return false; + }; + let Some(outcome) = + wxc_common::telemetry::consent_cli::handle_maintenance_input(input, is_base64) + 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 +148,15 @@ 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; + } + if handle_telemetry_consent_input(&cli) { + return; + } + // Determine config input. let (config_data, is_base64) = if let Some(ref b64) = cli.config_base64 { (b64.clone(), true) @@ -127,21 +202,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 @@ -172,7 +242,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..8bc30e930 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 @@ -1681,6 +1692,38 @@ mod tests { assert!(request.inner.experimental_enabled); } + #[test] + fn telemetry_enablement_is_stable_and_independent_of_experimental_mode() { + let mut request = build_request(&minimal_policy(), None).expect("build_request"); + assert!(request.inner.telemetry.is_none()); + assert!(!request.inner.experimental_enabled); + + request.set_telemetry_enabled(true); + assert_eq!( + request + .inner + .telemetry + .as_ref() + .and_then(|telemetry| telemetry.enabled), + Some(true) + ); + assert!( + !request.inner.experimental_enabled, + "stable telemetry enablement must not opt into experimental features" + ); + + request.set_telemetry_enabled(false); + assert_eq!( + request + .inner + .telemetry + .as_ref() + .and_then(|telemetry| telemetry.enabled), + Some(false) + ); + assert!(!request.inner.experimental_enabled); + } + #[test] fn wslc_rejects_an_invalid_port_mapping() { // Validation is the shared parser's, so a bad mapping is rejected at 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 eb8d9ab39..8a8c98327 100644 --- a/src/core/wxc/src/main.rs +++ b/src/core/wxc/src/main.rs @@ -111,6 +111,29 @@ 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 the same typed + /// response as JSON maintenance action `status`. 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, + + /// Legacy non-mutating tombstone. Returns a migration error directing the + /// caller to the typed JSON consent maintenance envelope. + #[arg(long = "telemetry-consent-grant")] + telemetry_consent_grant: bool, + + /// Legacy non-mutating tombstone. Returns a migration error directing the + /// caller to JSON action `withdraw`. + #[arg(long = "telemetry-consent-revoke")] + telemetry_consent_revoke: bool, + + /// Legacy companion tombstone for the removed grant/revoke flow. + #[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 /// 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 @@ -217,6 +240,51 @@ fn validate_audit_request(request: &ExecutionRequest) -> Result<(), String> { Ok(()) } +/// 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 handle_telemetry_consent_input(cli: &Cli) -> bool { + let Some((input, is_base64)) = config_input(cli) else { + return false; + }; + let Some(outcome) = telemetry::consent_cli::handle_maintenance_input(&input, is_base64) else { + return false; + }; + let code = outcome.emit(); + if code != 0 { + std::process::exit(code); + } + true +} + fn command_override_from_cli( cli: &Cli, context: CommandLineContext, @@ -344,19 +412,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; @@ -377,17 +436,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 @@ -745,6 +799,23 @@ 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; + } + if handle_telemetry_consent_input(&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. @@ -1023,7 +1094,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()); @@ -1041,17 +1112,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 @@ -1375,12 +1441,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/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/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index 2dd544961..03cdd5bcb 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`]. @@ -120,7 +139,10 @@ pub fn load_request_with_options( opts: LoadOptions, ) -> Result { let result = (|| { - let json_str = decode_request_input_without_logging(input, opts.is_base64)?; + let json_str = decode_request_input(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()))?; @@ -180,8 +213,7 @@ pub fn load_mxc_request_with_options( opts: LoadOptions, ) -> Result { let result = (|| { - let json_str = decode_request_input_without_logging(input, opts.is_base64) - .map_err(ParseError::Decode)?; + let json_str = decode_request_input(input, opts.is_base64).map_err(ParseError::Decode)?; parse_mxc_request_json(&json_str, logger, opts.allow_missing_command) })(); @@ -229,6 +261,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) @@ -251,7 +284,11 @@ fn log_error(logger: &mut Logger, message: &str, output: ErrorOutput) { } /// Reads a request from disk or decodes it from base64. -fn decode_request_input_without_logging(input: &str, is_base64: bool) -> Result { +/// Decode a config/maintenance input supplied as a file path or base64 JSON. +/// +/// This performs no logging so callers can apply the correct output contract +/// after discriminating execution requests from maintenance commands. +pub fn decode_request_input(input: &str, is_base64: bool) -> Result { if is_base64 { let bytes = base64_decode(input).map_err(|_| { WxcError::ConfigParse("Failed to decode base64 configuration".to_string()) @@ -1279,14 +1316,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 +1328,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 +1359,7 @@ fn convert_wire_config( policy, lxc_config, seatbelt, + telemetry, experimental_enabled: false, testing_features_enabled: false, experimental, @@ -1395,6 +1432,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 +1486,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 +1721,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 +1748,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 +1760,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 +1775,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 +1798,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 +1848,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 +5595,80 @@ 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 telemetry_rejects_unknown_fields() { + let json = r#"{"process":{"commandLine":"echo hi"},"telemetry":{"enable":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("telemetry.enable")); + } + + #[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 200c613bb..f3a4bbb55 100644 --- a/src/core/wxc_common/src/models.rs +++ b/src/core/wxc_common/src/models.rs @@ -680,16 +680,15 @@ 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 { - /// 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, } @@ -715,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/consent.rs b/src/core/wxc_common/src/telemetry/consent.rs new file mode 100644 index 000000000..9c837736d --- /dev/null +++ b/src/core/wxc_common/src/telemetry/consent.rs @@ -0,0 +1,1312 @@ +// 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}; +use std::fmt; + +use super::consent_prompt::{prompt_for_locale, ConsentPrompt}; + +/// 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 = 2; + +/// 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, +} + +/// Why persisted consent does not currently authorize collection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConsentStatusReason { + /// No consent record exists for this user. + NoRecord, + /// The consent store could not be read. + StoreUnreadable, + /// The consent record was not valid JSON or did not have a recognized + /// consent value. + StoreMalformed, + /// The persisted consent schema is not supported by this build. + ConsentSchemaUnsupported, + /// A stored grant predates versioned canonical consent language. + PromptVersionMissing, + /// A stored grant references a different canonical prompt version. + PromptVersionUnsupported, + /// Telemetry consent is not applicable on this platform. + NotApplicable, +} + +impl ConsentStatusReason { + /// Stable wire representation used by status APIs and bindings. + pub fn as_str(&self) -> &'static str { + match self { + Self::NoRecord => "no-record", + Self::StoreUnreadable => "store-unreadable", + Self::StoreMalformed => "store-malformed", + Self::ConsentSchemaUnsupported => "consent-schema-unsupported", + Self::PromptVersionMissing => "prompt-version-missing", + Self::PromptVersionUnsupported => "prompt-version-unsupported", + Self::NotApplicable => "not-applicable", + } + } +} + +/// Persisted and effective telemetry consent at one point in time. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConsentStatus { + /// Decision found in the consent record, when one could be recovered. + pub stored_state: ConsentState, + /// State used by the telemetry gate after validating the record and prompt + /// version. + pub effective_state: ConsentState, + /// Fail-closed reason when the record is absent, invalid, or inapplicable. + pub reason: Option, +} + +impl ConsentStatus { + /// Whether an explicit consent request may offer the canonical prompt. + pub fn needs_prompt(&self) -> bool { + matches!(self.effective_state, ConsentState::Undetermined) + } +} + +/// Explicit result returned by a consent presenter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConsentDecision { + Yes, + No, + Dismissed, +} + +/// Result of requesting or withdrawing telemetry consent. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConsentActionResult { + Granted, + Denied, + Dismissed, + Withdrawn, + AlreadyGranted, + PolicyBlocked, + NotApplicable, +} + +/// Consent action result together with the resulting status and policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConsentActionOutcome { + pub result: ConsentActionResult, + pub status: ConsentStatus, + pub policy: super::policy::PolicyState, +} + +/// Failure to present or persist a consent decision. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConsentActionError { + Presenter(String), + Persist(String), +} + +impl fmt::Display for ConsentActionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Presenter(message) => write!(formatter, "consent presenter failed: {message}"), + Self::Persist(message) => write!(formatter, "failed to persist consent: {message}"), + } + } +} + +impl std::error::Error for ConsentActionError {} + +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, + #[serde(rename = "promptResourceVersion", default)] + prompt_resource_version: Option, + #[serde(rename = "promptLocale", default)] + prompt_locale: 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 { + get_status().effective_state +} + +/// Returns persisted and effective consent plus a typed fail-closed reason. +pub fn get_status() -> ConsentStatus { + platform::read_status() +} + +/// 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) +} + +/// Request telemetry consent through a host-owned presenter. +/// +/// The presenter receives the complete canonical resource. Only an explicit +/// [`ConsentDecision::Yes`] returned from this invocation can create a grant. +pub fn request_consent( + locale: Option<&str>, + presenter: F, +) -> Result +where + F: FnOnce(&ConsentPrompt) -> Result, +{ + let (prompt, policy, status) = match consent_preflight(locale) { + ConsentPreflight::Complete(outcome) => return Ok(outcome), + ConsentPreflight::Present { + prompt, + policy, + status, + } => (prompt, policy, status), + }; + + let decision = presenter(prompt).map_err(ConsentActionError::Presenter)?; + persist_presented_decision(decision, prompt, policy, status) +} + +/// Asynchronous counterpart to [`request_consent`]. +pub async fn request_consent_async( + locale: Option<&str>, + presenter: F, +) -> Result +where + F: FnOnce(&ConsentPrompt) -> Fut, + Fut: std::future::Future>, +{ + match consent_preflight(locale) { + ConsentPreflight::Complete(outcome) => Ok(outcome), + ConsentPreflight::Present { + prompt, + policy, + status, + } => { + let decision = presenter(prompt) + .await + .map_err(ConsentActionError::Presenter)?; + persist_presented_decision(decision, prompt, policy, status) + } + } +} + +enum ConsentPreflight { + Complete(ConsentActionOutcome), + Present { + prompt: &'static ConsentPrompt, + policy: super::policy::PolicyState, + status: ConsentStatus, + }, +} + +fn consent_preflight(locale: Option<&str>) -> ConsentPreflight { + let policy = super::policy::get_policy(); + let status = get_status(); + + if status.effective_state == ConsentState::NotApplicable { + return ConsentPreflight::Complete(ConsentActionOutcome { + result: ConsentActionResult::NotApplicable, + status, + policy, + }); + } + if !policy.allows_collection() { + return ConsentPreflight::Complete(ConsentActionOutcome { + result: ConsentActionResult::PolicyBlocked, + status, + policy, + }); + } + if status.effective_state == ConsentState::Granted { + return ConsentPreflight::Complete(ConsentActionOutcome { + result: ConsentActionResult::AlreadyGranted, + status, + policy, + }); + } + + let prompt = prompt_for_locale(locale); + ConsentPreflight::Present { + prompt, + policy, + status, + } +} + +fn persist_presented_decision( + decision: ConsentDecision, + prompt: &ConsentPrompt, + policy: super::policy::PolicyState, + status: ConsentStatus, +) -> Result { + match decision { + ConsentDecision::Yes => { + platform::write_presented(true, "prompt", prompt) + .map_err(ConsentActionError::Persist)?; + Ok(ConsentActionOutcome { + result: ConsentActionResult::Granted, + status: get_status(), + policy, + }) + } + ConsentDecision::No => { + platform::write_presented(false, "prompt", prompt) + .map_err(ConsentActionError::Persist)?; + Ok(ConsentActionOutcome { + result: ConsentActionResult::Denied, + status: get_status(), + policy, + }) + } + ConsentDecision::Dismissed => Ok(ConsentActionOutcome { + result: ConsentActionResult::Dismissed, + status, + policy, + }), + } +} + +/// Idempotently withdraw telemetry consent. +/// +/// Withdrawal does not require a permitting administrative policy. Off +/// Windows it succeeds as a typed `NotApplicable` result without storage. +pub fn withdraw_consent() -> Result { + let policy = super::policy::get_policy(); + let status = get_status(); + if status.effective_state == ConsentState::NotApplicable { + return Ok(ConsentActionOutcome { + result: ConsentActionResult::NotApplicable, + status, + policy, + }); + } + + platform::write(false, "withdrawal").map_err(ConsentActionError::Persist)?; + Ok(ConsentActionOutcome { + result: ConsentActionResult::Withdrawn, + status: get_status(), + policy, + }) +} + +/// 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, ConsentStatus, ConsentStatusReason, + CONSENT_SCHEMA_VERSION, + }; + use crate::telemetry::consent_prompt::{CONSENT_RESOURCE_VERSION, FALLBACK_LOCALE}; + 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 | TOKEN_IMPERSONATE`. + /// + /// 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. + /// + /// `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_IMPERSONATE, 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 | TOKEN_IMPERSONATE, + &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_status() -> ConsentStatus { + let Some(path) = consent_file_path() else { + return ConsentStatus { + stored_state: ConsentState::Undetermined, + effective_state: ConsentState::Undetermined, + reason: Some(ConsentStatusReason::StoreUnreadable), + }; + }; + let data = match with_io_retry(|| fs::read_to_string(&path)) { + Ok(data) => data, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return ConsentStatus { + stored_state: ConsentState::Undetermined, + effective_state: ConsentState::Undetermined, + reason: Some(ConsentStatusReason::NoRecord), + }; + } + Err(_) => { + return ConsentStatus { + stored_state: ConsentState::Undetermined, + effective_state: ConsentState::Undetermined, + reason: Some(ConsentStatusReason::StoreUnreadable), + }; + } + }; + let Ok(record) = serde_json::from_str::(&data) else { + return ConsentStatus { + stored_state: ConsentState::Undetermined, + effective_state: ConsentState::Undetermined, + reason: Some(ConsentStatusReason::StoreMalformed), + }; + }; + + let stored_state = match record.consent.as_str() { + "granted" => ConsentState::Granted, + "denied" => ConsentState::Denied, + _ => { + return ConsentStatus { + stored_state: ConsentState::Undetermined, + effective_state: ConsentState::Undetermined, + reason: Some(ConsentStatusReason::StoreMalformed), + }; + } + }; + + if stored_state == ConsentState::Denied { + return ConsentStatus { + stored_state, + effective_state: ConsentState::Denied, + reason: None, + }; + } + + if record.schema_version != CONSENT_SCHEMA_VERSION { + return ConsentStatus { + stored_state, + effective_state: ConsentState::Undetermined, + reason: Some(if record.prompt_resource_version.is_none() { + ConsentStatusReason::PromptVersionMissing + } else { + ConsentStatusReason::ConsentSchemaUnsupported + }), + }; + } + + match record.prompt_resource_version { + Some(CONSENT_RESOURCE_VERSION) if !record.prompt_locale.is_empty() => ConsentStatus { + stored_state, + effective_state: ConsentState::Granted, + reason: None, + }, + None => ConsentStatus { + stored_state, + effective_state: ConsentState::Undetermined, + reason: Some(ConsentStatusReason::PromptVersionMissing), + }, + Some(_) => ConsentStatus { + stored_state, + effective_state: ConsentState::Undetermined, + reason: Some(ConsentStatusReason::PromptVersionUnsupported), + }, + } + } + + /// 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> { + write_record( + granted, + source, + Some(CONSENT_RESOURCE_VERSION), + FALLBACK_LOCALE, + ) + } + + pub(super) fn write_presented( + granted: bool, + source: &str, + prompt: &crate::telemetry::consent_prompt::ConsentPrompt, + ) -> Result<(), String> { + write_record( + granted, + source, + Some(prompt.resource_version), + prompt.locale, + ) + } + + fn write_record( + granted: bool, + source: &str, + prompt_resource_version: Option, + prompt_locale: &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(), + prompt_resource_version, + prompt_locale: prompt_locale.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, ConsentStatus, ConsentStatusReason}; + + pub(super) fn read_status() -> ConsentStatus { + ConsentStatus { + stored_state: ConsentState::NotApplicable, + effective_state: ConsentState::NotApplicable, + reason: Some(ConsentStatusReason::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()) + } + + pub(super) fn write_presented( + _granted: bool, + _source: &str, + _prompt: &crate::telemetry::consent_prompt::ConsentPrompt, + ) -> 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, get_status, needs_consent_prompt, request_consent, set_consent, + withdraw_consent, ConsentActionError, ConsentActionResult, ConsentDecision, + ConsentState, ConsentStatusReason, + }; + + #[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 presenter_yes_persists_a_current_version_grant() { + let tmp = tempfile::tempdir().unwrap(); + let env = crate::telemetry::test_support::TelemetryTestEnv::new(tmp.path()); + env.set_policy_value(3); + + let outcome = request_consent(Some("en-US"), |prompt| { + assert_eq!(prompt.resource_version, 1); + assert_eq!(prompt.locale, "en-US"); + Ok(ConsentDecision::Yes) + }) + .expect("consent request"); + + assert_eq!(outcome.result, ConsentActionResult::Granted); + assert_eq!(outcome.status.stored_state, ConsentState::Granted); + assert_eq!(outcome.status.effective_state, ConsentState::Granted); + assert_eq!(outcome.status.reason, None); + } + + #[test] + fn explicit_request_can_replace_a_prior_denial() { + let tmp = tempfile::tempdir().unwrap(); + let env = crate::telemetry::test_support::TelemetryTestEnv::new(tmp.path()); + env.set_policy_value(3); + set_consent(false, "prompt").unwrap(); + + let outcome = request_consent(None, |_| Ok(ConsentDecision::Yes)).unwrap(); + + assert_eq!(outcome.result, ConsentActionResult::Granted); + assert_eq!(get_consent(), ConsentState::Granted); + } + + #[test] + fn dismissed_or_failed_presenter_does_not_rewrite_prior_state() { + let tmp = tempfile::tempdir().unwrap(); + let env = crate::telemetry::test_support::TelemetryTestEnv::new(tmp.path()); + env.set_policy_value(3); + set_consent(false, "prompt").unwrap(); + + let dismissed = + request_consent(None, |_| Ok(ConsentDecision::Dismissed)).expect("dismissal"); + assert_eq!(dismissed.result, ConsentActionResult::Dismissed); + assert_eq!(get_consent(), ConsentState::Denied); + + let error = request_consent(None, |_| Err("host UI failed".to_string())) + .expect_err("presenter failure"); + assert_eq!( + error, + ConsentActionError::Presenter("host UI failed".to_string()) + ); + assert_eq!(get_consent(), ConsentState::Denied); + } + + #[test] + fn policy_block_and_current_grant_skip_the_presenter() { + let tmp = tempfile::tempdir().unwrap(); + let env = crate::telemetry::test_support::TelemetryTestEnv::new(tmp.path()); + env.set_policy_value(0); + let blocked = request_consent(None, |_| panic!("blocked policy must not present")) + .expect("blocked result"); + assert_eq!(blocked.result, ConsentActionResult::PolicyBlocked); + + env.set_policy_value(3); + set_consent(true, "prompt").unwrap(); + let granted = request_consent(None, |_| panic!("current grant must not re-present")) + .expect("already granted result"); + assert_eq!(granted.result, ConsentActionResult::AlreadyGranted); + } + + #[test] + fn withdrawal_is_idempotent_and_ignores_blocking_policy() { + let tmp = tempfile::tempdir().unwrap(); + let env = crate::telemetry::test_support::TelemetryTestEnv::new(tmp.path()); + env.set_policy_value(3); + set_consent(true, "prompt").unwrap(); + env.set_policy_value(0); + + for _ in 0..2 { + let outcome = withdraw_consent().expect("withdrawal"); + assert_eq!(outcome.result, ConsentActionResult::Withdrawn); + assert_eq!(outcome.status.effective_state, ConsentState::Denied); + } + } + + #[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 + )); + } + + /// Pre-versioned grants must be invalidated because they cannot prove + /// the canonical prompt was shown. A prior denial remains denied. + #[test] + fn legacy_records_preserve_provenance_but_require_reconsent_for_grants() { + for (stored, effective, reason) in [ + ( + "granted", + ConsentState::Undetermined, + Some(ConsentStatusReason::PromptVersionMissing), + ), + ("denied", ConsentState::Denied, None), + ] { + 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(); + let status = get_status(); + assert_eq!( + status.stored_state, + if stored == "granted" { + ConsentState::Granted + } else { + ConsentState::Denied + } + ); + assert_eq!(status.effective_state, effective); + assert_eq!(status.reason, reason); + } + } + + /// `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..3d687586b --- /dev/null +++ b/src/core/wxc_common/src/telemetry/consent_cli.rs @@ -0,0 +1,687 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Shared telemetry-consent maintenance handling for executor binaries. +//! +//! 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::Deserialize; +use std::io::{IsTerminal, Write}; + +use super::{consent, consent_prompt, policy}; +use crate::wire; + +const PRESENTER_PROTOCOL_ENV: &str = "MXC_TELEMETRY_CONSENT_PRESENTER_PROTOCOL"; + +/// 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>, +} + +/// 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, + } + } + + fn json(response: &wire::TelemetryConsentMaintenanceResponse) -> Self { + match serde_json::to_string(response) { + Ok(json) => Self { + stdout: Some(json), + stderr: None, + exit_code: 0, + }, + Err(error) => Self::failure( + format!("Error: failed to serialize telemetry consent response: {error}"), + 1, + ), + } + } +} + +/// 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 || flags.source.is_some()) { + return None; + } + + if flags.grant || flags.revoke || flags.source.is_some() { + return Some(ConsentCliOutcome::failure( + "Error: --telemetry-consent-grant, --telemetry-consent-revoke, and \ + --telemetry-consent-source no longer change consent. Use the typed \ + telemetry-consent JSON maintenance envelope instead." + .to_string(), + 64, + )); + } + + Some(ConsentCliOutcome::json(&maintenance_response( + wire::TelemetryConsentAction::Status, + wire::TelemetryConsentResult::Status, + None, + None, + None, + ))) +} + +#[derive(Deserialize)] +struct MaintenanceDiscriminator { + command: Option, +} + +/// Handle a typed telemetry-consent maintenance envelope supplied through the +/// executor's existing file/base64 JSON input path. +/// +/// Returns `None` for ordinary execution JSON so the caller can continue into +/// the existing execution parser. +pub fn handle_maintenance_input(input: &str, is_base64: bool) -> Option { + let json = match crate::config_parser::decode_request_input(input, is_base64) { + Ok(json) => json, + Err(_) => return None, + }; + let discriminator: MaintenanceDiscriminator = match serde_json::from_str(&json) { + Ok(value) => value, + Err(_) => return None, + }; + if discriminator.command.as_deref() != Some("telemetryConsent") { + return None; + } + + let request: wire::TelemetryConsentMaintenanceRequest = + match crate::config_deserialize::from_str(&json) { + Ok(request) => request, + Err(error) => { + return Some(ConsentCliOutcome::failure( + format!("Error: invalid telemetry consent maintenance request: {error}"), + 64, + )); + } + }; + + Some(handle_maintenance_request(request)) +} + +fn handle_maintenance_request( + request: wire::TelemetryConsentMaintenanceRequest, +) -> ConsentCliOutcome { + match request.action { + wire::TelemetryConsentAction::Status => ConsentCliOutcome::json(&maintenance_response( + request.action, + wire::TelemetryConsentResult::Status, + None, + None, + None, + )), + wire::TelemetryConsentAction::Withdraw => match consent::withdraw_consent() { + Ok(outcome) => ConsentCliOutcome::json(&maintenance_response( + request.action, + action_result_to_wire(outcome.result), + None, + None, + None, + )), + Err(error) => ConsentCliOutcome::failure(format!("Error: {error}"), 1), + }, + wire::TelemetryConsentAction::Request => { + let protocol = + std::env::var_os(PRESENTER_PROTOCOL_ENV).is_some_and(|value| value == "1"); + let result = consent::request_consent(request.locale.as_deref(), |prompt| { + if protocol { + present_over_stdio_protocol(request.action, prompt) + } else { + present_on_terminal(prompt) + } + }); + match result { + Ok(outcome) => ConsentCliOutcome::json(&maintenance_response( + request.action, + action_result_to_wire(outcome.result), + None, + None, + None, + )), + Err(consent::ConsentActionError::Presenter(error)) => { + let response = maintenance_response( + request.action, + wire::TelemetryConsentResult::PresentationUnavailable, + Some(wire::TelemetryConsentStatusReason::PresentationUnavailable), + None, + None, + ); + let mut outcome = ConsentCliOutcome::json(&response); + outcome.stderr = Some(format!("Error: {error}")); + outcome.exit_code = 1; + outcome + } + Err(error) => ConsentCliOutcome::failure(format!("Error: {error}"), 1), + } + } + } +} + +fn present_on_terminal( + prompt: &consent_prompt::ConsentPrompt, +) -> Result { + if !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() { + return Err("interactive telemetry consent presentation is unavailable".to_string()); + } + + eprintln!("{}", prompt.title.text); + eprintln!(); + eprintln!("{}", prompt.body.text); + eprintln!(); + eprintln!( + "{}: {}", + prompt.learn_more_label.text, prompt.learn_more_url + ); + eprintln!(); + eprintln!("[Y] {}", prompt.affirmative_label.text); + eprintln!("[N] {}", prompt.negative_label.text); + eprint!("Enter Y or N: "); + std::io::stderr() + .flush() + .map_err(|error| format!("failed to display telemetry consent prompt: {error}"))?; + + let mut input = String::new(); + let read = std::io::stdin() + .read_line(&mut input) + .map_err(|error| format!("failed to read telemetry consent response: {error}"))?; + if read == 0 { + return Ok(consent::ConsentDecision::Dismissed); + } + match input.trim().to_ascii_lowercase().as_str() { + "y" | "yes" => Ok(consent::ConsentDecision::Yes), + "n" | "no" => Ok(consent::ConsentDecision::No), + _ => Ok(consent::ConsentDecision::Dismissed), + } +} + +fn present_over_stdio_protocol( + action: wire::TelemetryConsentAction, + prompt: &consent_prompt::ConsentPrompt, +) -> Result { + let challenge = random_challenge()?; + let presentation = maintenance_response( + action, + wire::TelemetryConsentResult::PresentationRequired, + None, + Some(prompt_to_wire(prompt)), + Some(challenge.clone()), + ); + let json = serde_json::to_string(&presentation) + .map_err(|error| format!("failed to serialize consent presentation: {error}"))?; + println!("{json}"); + std::io::stdout() + .flush() + .map_err(|error| format!("failed to flush consent presentation: {error}"))?; + + let mut input = String::new(); + let read = std::io::stdin() + .read_line(&mut input) + .map_err(|error| format!("failed to read consent presenter response: {error}"))?; + if read == 0 { + return Ok(consent::ConsentDecision::Dismissed); + } + let response: wire::TelemetryConsentPresenterResponse = serde_json::from_str(&input) + .map_err(|error| format!("invalid consent presenter response for this request: {error}"))?; + validate_presenter_response(response, &challenge, prompt.resource_version) +} + +fn validate_presenter_response( + response: wire::TelemetryConsentPresenterResponse, + expected_challenge: &str, + expected_resource_version: u32, +) -> Result { + if response.challenge != expected_challenge { + return Err("consent presenter challenge did not match this request".to_string()); + } + if response.resource_version != expected_resource_version { + return Err("consent presenter prompt version did not match this request".to_string()); + } + Ok(match response.decision { + wire::TelemetryConsentDecision::Yes => consent::ConsentDecision::Yes, + wire::TelemetryConsentDecision::No => consent::ConsentDecision::No, + wire::TelemetryConsentDecision::Dismissed => consent::ConsentDecision::Dismissed, + }) +} + +fn random_challenge() -> Result { + let mut bytes = [0_u8; 32]; + getrandom::getrandom(&mut bytes) + .map_err(|error| format!("failed to create consent presenter challenge: {error}"))?; + let mut encoded = String::with_capacity(bytes.len() * 2); + for byte in bytes { + use std::fmt::Write as _; + write!(&mut encoded, "{byte:02x}") + .map_err(|error| format!("failed to encode consent presenter challenge: {error}"))?; + } + Ok(encoded) +} + +fn maintenance_response( + action: wire::TelemetryConsentAction, + result: wire::TelemetryConsentResult, + reason: Option, + prompt: Option, + challenge: Option, +) -> wire::TelemetryConsentMaintenanceResponse { + let status = consent::get_status(); + let policy_state = policy::get_policy(); + wire::TelemetryConsentMaintenanceResponse { + action, + result, + stored_state: consent_state_to_wire(status.stored_state), + effective_state: consent_state_to_wire(status.effective_state), + reason: reason.or_else(|| status.reason.map(status_reason_to_wire)), + policy: policy_state_to_wire(policy_state), + needs_prompt: policy_state.allows_collection() && status.needs_prompt(), + prompt, + challenge, + } +} + +fn prompt_to_wire(prompt: &consent_prompt::ConsentPrompt) -> wire::TelemetryConsentPrompt { + fn message(value: consent_prompt::ConsentMessage) -> wire::TelemetryConsentMessage { + wire::TelemetryConsentMessage { + id: value.id.to_string(), + text: value.text.to_string(), + } + } + + wire::TelemetryConsentPrompt { + resource_version: prompt.resource_version, + locale: prompt.locale.to_string(), + title: message(prompt.title), + body: message(prompt.body), + affirmative_label: message(prompt.affirmative_label), + negative_label: message(prompt.negative_label), + learn_more_label: message(prompt.learn_more_label), + learn_more_url: prompt.learn_more_url.to_string(), + } +} + +fn consent_state_to_wire(state: consent::ConsentState) -> wire::TelemetryConsentState { + match state { + consent::ConsentState::Granted => wire::TelemetryConsentState::Granted, + consent::ConsentState::Denied => wire::TelemetryConsentState::Denied, + consent::ConsentState::Undetermined => wire::TelemetryConsentState::Undetermined, + consent::ConsentState::NotApplicable => wire::TelemetryConsentState::NotApplicable, + } +} + +fn status_reason_to_wire( + reason: consent::ConsentStatusReason, +) -> wire::TelemetryConsentStatusReason { + match reason { + consent::ConsentStatusReason::NoRecord => wire::TelemetryConsentStatusReason::NoRecord, + consent::ConsentStatusReason::StoreUnreadable => { + wire::TelemetryConsentStatusReason::StoreUnreadable + } + consent::ConsentStatusReason::StoreMalformed => { + wire::TelemetryConsentStatusReason::StoreMalformed + } + consent::ConsentStatusReason::ConsentSchemaUnsupported => { + wire::TelemetryConsentStatusReason::ConsentSchemaUnsupported + } + consent::ConsentStatusReason::PromptVersionMissing => { + wire::TelemetryConsentStatusReason::PromptVersionMissing + } + consent::ConsentStatusReason::PromptVersionUnsupported => { + wire::TelemetryConsentStatusReason::PromptVersionUnsupported + } + consent::ConsentStatusReason::NotApplicable => { + wire::TelemetryConsentStatusReason::NotApplicable + } + } +} + +fn policy_state_to_wire(state: policy::PolicyState) -> wire::TelemetryConsentPolicyState { + match state { + policy::PolicyState::Unrestricted => wire::TelemetryConsentPolicyState::Unrestricted, + policy::PolicyState::Allowed => wire::TelemetryConsentPolicyState::Allowed, + policy::PolicyState::Blocked => wire::TelemetryConsentPolicyState::Blocked, + policy::PolicyState::NotApplicable => wire::TelemetryConsentPolicyState::NotApplicable, + } +} + +fn action_result_to_wire(result: consent::ConsentActionResult) -> wire::TelemetryConsentResult { + match result { + consent::ConsentActionResult::Granted => wire::TelemetryConsentResult::Granted, + consent::ConsentActionResult::Denied => wire::TelemetryConsentResult::Denied, + consent::ConsentActionResult::Dismissed => wire::TelemetryConsentResult::Dismissed, + consent::ConsentActionResult::Withdrawn => wire::TelemetryConsentResult::Withdrawn, + consent::ConsentActionResult::AlreadyGranted => { + wire::TelemetryConsentResult::AlreadyGranted + } + consent::ConsentActionResult::PolicyBlocked => wire::TelemetryConsentResult::PolicyBlocked, + consent::ConsentActionResult::NotApplicable => wire::TelemetryConsentResult::NotApplicable, + } +} + +#[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()); + } + + fn assert_status_json( + outcome: &ConsentCliOutcome, + stored: &str, + effective: &str, + policy: &str, + needs_prompt: bool, + ) { + let value: serde_json::Value = + serde_json::from_str(outcome.stdout.as_deref().expect("status JSON")).unwrap(); + assert_eq!(value["action"], "status"); + assert_eq!(value["result"], "status"); + assert_eq!(value["storedState"], stored); + assert_eq!(value["effectiveState"], effective); + assert_eq!(value["policy"], policy); + assert_eq!(value["needsPrompt"], needs_prompt); + } + + /// Previously unreachable in-process: this branch called + /// `std::process::exit(64)` and would have killed the test runner. + #[test] + fn legacy_state_changing_flags_return_a_migration_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("JSON maintenance envelope")); + } + + #[test] + fn presenter_response_is_bound_to_challenge_and_prompt_version() { + let valid = wire::TelemetryConsentPresenterResponse { + challenge: "request-a".to_string(), + resource_version: 1, + decision: wire::TelemetryConsentDecision::Yes, + }; + assert_eq!( + validate_presenter_response(valid, "request-a", 1).unwrap(), + consent::ConsentDecision::Yes + ); + + let replay = wire::TelemetryConsentPresenterResponse { + challenge: "request-a".to_string(), + resource_version: 1, + decision: wire::TelemetryConsentDecision::Yes, + }; + assert!(validate_presenter_response(replay, "request-b", 1) + .unwrap_err() + .contains("challenge")); + + let stale_prompt = wire::TelemetryConsentPresenterResponse { + challenge: "request-a".to_string(), + resource_version: 0, + decision: wire::TelemetryConsentDecision::Yes, + }; + assert!(validate_presenter_response(stale_prompt, "request-a", 1) + .unwrap_err() + .contains("version")); + } + + /// 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_status_json( + &outcome, + "not-applicable", + "not-applicable", + "not-applicable", + false, + ); + 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, 64); + assert_eq!(outcome.stdout, None); + assert!(outcome + .stderr + .as_deref() + .unwrap() + .contains("JSON maintenance envelope")); + } + + #[test] + fn revoke_is_refused() { + let outcome = handle_consent_flags(&flags(false, false, true)).expect("handled"); + assert_eq!(outcome.exit_code, 64); + assert_eq!(outcome.stdout, None); + assert!(outcome + .stderr + .as_deref() + .unwrap() + .contains("JSON maintenance envelope")); + } + } + + /// 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_is_a_non_mutating_migration_tombstone() { + 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, 64); + assert_eq!(outcome.stdout, None); + assert_eq!(consent::get_consent().as_str(), "undetermined"); + } + + #[test] + fn revoke_flag_is_a_non_mutating_migration_tombstone() { + 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, 64); + assert_eq!(outcome.stdout, None); + assert_eq!(consent::get_consent().as_str(), "undetermined"); + } + + #[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_status_json( + &outcome, + "undetermined", + "undetermined", + "unrestricted", + true, + ); + 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_status_json(&outcome, "undetermined", "undetermined", "blocked", false); + } + + /// 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_tombstone_does_not_record_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, 64); + assert_eq!(consent::get_consent(), consent::ConsentState::Undetermined); + 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 withdrawal_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_maintenance_request(wire::TelemetryConsentMaintenanceRequest { + schema: None, + command: wire::TelemetryConsentCommand::TelemetryConsent, + action: wire::TelemetryConsentAction::Withdraw, + locale: None, + }); + 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/consent_prompt.rs b/src/core/wxc_common/src/telemetry/consent_prompt.rs new file mode 100644 index 000000000..12749f421 --- /dev/null +++ b/src/core/wxc_common/src/telemetry/consent_prompt.rs @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Canonical, versioned telemetry consent resources. +//! +//! Rust owns the complete resource so the executor and every SDK binding +//! present identical language. Hosts may control layout and accessibility, but +//! must render every field verbatim before returning an affirmative decision. + +/// The current telemetry consent resource version. +/// +/// A grant is valid only for this exact version. Increment it when the meaning +/// of consent or the disclosed data categories change. +pub const CONSENT_RESOURCE_VERSION: u32 = 1; + +/// The mandatory fallback locale. +pub const FALLBACK_LOCALE: &str = "en-US"; + +/// One independently localizable consent message. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConsentMessage { + /// Stable identifier used by future generated localization resources. + pub id: &'static str, + /// Complete localized text. Sentence fragments are never concatenated. + pub text: &'static str, +} + +/// The complete canonical telemetry consent resource. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConsentPrompt { + /// Version bound to a persisted affirmative decision. + pub resource_version: u32, + /// BCP 47 locale of every message in this resource. + pub locale: &'static str, + /// Prompt title. + pub title: ConsentMessage, + /// Full disclosure body. + pub body: ConsentMessage, + /// Affirmative action label. + pub affirmative_label: ConsentMessage, + /// Negative action label. + pub negative_label: ConsentMessage, + /// Learn-more link label. + pub learn_more_label: ConsentMessage, + /// Learn-more URL. + pub learn_more_url: &'static str, +} + +/// Canonical US English consent resource, version 1. +pub const EN_US_CONSENT_PROMPT: ConsentPrompt = ConsentPrompt { + resource_version: CONSENT_RESOURCE_VERSION, + locale: FALLBACK_LOCALE, + title: ConsentMessage { + id: "telemetry-consent-title", + text: "Help improve Microsoft eXecution Container (MXC)", + }, + body: ConsentMessage { + id: "telemetry-consent-body", + text: r#"Help improve MXC by sharing optional diagnostic data with Microsoft. +If enabled, MXC sends diagnostic information about product usage, performance, and reliability. MXC does not send your commands, file paths, credentials, or other customer content. +You can change your choice at any time."#, + }, + affirmative_label: ConsentMessage { + id: "telemetry-consent-affirmative-label", + text: "Yes", + }, + negative_label: ConsentMessage { + id: "telemetry-consent-negative-label", + text: "No", + }, + learn_more_label: ConsentMessage { + id: "telemetry-consent-learn-more-label", + text: "Privacy Statement", + }, + learn_more_url: "https://go.microsoft.com/fwlink/?linkid=521839", +}; + +/// Resolve the best available resource for a requested BCP 47 locale. +/// +/// Only `en-US` is currently shipped. The lookup still accepts normalized +/// English tags and always falls back to `en-US`, leaving room for a generated +/// resource provider without changing the public consent API. +pub fn prompt_for_locale(locale: Option<&str>) -> &'static ConsentPrompt { + locale + .and_then(find_prompt) + .unwrap_or(&EN_US_CONSENT_PROMPT) +} + +fn find_prompt(locale: &str) -> Option<&'static ConsentPrompt> { + is_english_locale(locale).then_some(&EN_US_CONSENT_PROMPT) +} + +fn is_english_locale(locale: &str) -> bool { + locale + .trim() + .split(['-', '_']) + .next() + .is_some_and(|language| language.eq_ignore_ascii_case("en")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn canonical_resource_is_complete_and_versioned() { + let prompt = prompt_for_locale(Some("en-US")); + assert_eq!(prompt.resource_version, 1); + assert_eq!(prompt.locale, "en-US"); + assert!(!prompt.title.text.is_empty()); + assert!(!prompt.body.text.is_empty()); + assert!(!prompt.affirmative_label.text.is_empty()); + assert!(!prompt.negative_label.text.is_empty()); + assert!(!prompt.learn_more_label.text.is_empty()); + assert_eq!( + prompt.learn_more_url, + "https://go.microsoft.com/fwlink/?linkid=521839" + ); + } + + #[test] + fn locale_lookup_normalizes_english_and_falls_back_to_en_us() { + for locale in [ + None, + Some("en"), + Some("EN_us"), + Some("en-GB"), + Some("fr-FR"), + ] { + assert_eq!(prompt_for_locale(locale), &EN_US_CONSENT_PROMPT); + } + } + + #[test] + fn message_ids_are_stable_and_unique() { + let prompt = &EN_US_CONSENT_PROMPT; + let ids = [ + prompt.title.id, + prompt.body.id, + prompt.affirmative_label.id, + prompt.negative_label.id, + prompt.learn_more_label.id, + ]; + for (index, id) in ids.iter().enumerate() { + assert!(!id.is_empty()); + assert!(!ids[..index].contains(id), "duplicate message id: {id}"); + } + } +} 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 15a85d4eb..2fde2cc96 100644 --- a/src/core/wxc_common/src/telemetry/mod.rs +++ b/src/core/wxc_common/src/telemetry/mod.rs @@ -11,8 +11,12 @@ //! //! On non-Windows platforms, all telemetry functions are no-ops. +pub mod consent; +pub mod consent_cli; +pub mod consent_prompt; pub mod correlation_vector; pub mod events; +pub mod policy; use std::time::Duration; @@ -24,7 +28,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 @@ -75,7 +81,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)] @@ -88,6 +94,11 @@ thread_local! { /// telemetry test's forced-active state and trip the global panic hook into /// the sink. Never set outside tests. static TEST_FORCE_ACTIVE: std::cell::Cell = const { std::cell::Cell::new(false) }; + /// Optional authorization result used to prove the live gate independently + /// of platform consent storage. + static TEST_AUTHORIZATION_OVERRIDE: std::cell::Cell> = const { + std::cell::Cell::new(None) + }; } /// Whether the emit glue should proceed. In production this is exactly @@ -101,6 +112,28 @@ fn emit_active() -> bool { mxc_telemetry::is_active() } +/// Re-read the mutable privacy gates immediately before an event write. +fn authorization_allows_emission() -> bool { + #[cfg(test)] + if let Some(allowed) = TEST_AUTHORIZATION_OVERRIDE.with(|value| value.get()) { + return allowed; + } + #[cfg(test)] + if TEST_FORCE_ACTIVE.with(|active| active.get()) { + return true; + } + + consent::get_consent().allows_collection() && policy::get_policy().allows_collection() +} + +fn invocation_can_emit(active: bool) -> bool { + active && authorization_allows_emission() +} + +fn process_can_emit() -> bool { + emit_active() && authorization_allows_emission() +} + /// Claim the single terminal-emit slot for this process. Returns `true` if a /// terminal event has *already* been emitted (so the caller should skip), /// `false` for the first caller (which then owns emission). @@ -121,6 +154,7 @@ fn reset_for_test() { .lock() .unwrap_or_else(|e| e.into_inner()) = None; TEST_FORCE_ACTIVE.with(|f| f.set(false)); + TEST_AUTHORIZATION_OVERRIDE.with(|value| value.set(None)); events::test_sink::clear(); } @@ -140,14 +174,38 @@ 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. 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 +/// leaves it off. `Some(true)` can never *bypass* consent — a config +/// author cannot turn telemetry on for someone who hasn't agreed to it. +/// +/// 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. /// -/// Note: Consent is the SDK consumer's responsibility. MXC does not implement -/// consent prompts or persistent consent storage. +/// 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 { - 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. @@ -200,8 +258,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, @@ -209,35 +267,46 @@ pub fn emit_completion( response: &ScriptResponse, elapsed: Duration, ) { - if !active { + if !invocation_can_emit(active) { return; } if already_emitted() { return; } + emit_completion_event(active, containment, response, elapsed); + shutdown(); +} +fn emit_completion_event( + active: bool, + containment: &ContainmentBackend, + response: &ScriptResponse, + elapsed: Duration, +) { let backend = containment.wire_name(); let failed = response.exit_code != 0; let outcome = if failed { "failure" } else { "success" }; let failure_reason = failed.then(|| classify_failure(&response.failure_phase)); - log_execution(&ExecutionEvent { - backend, - exit_code: response.exit_code, - outcome, - duration_ms: elapsed.as_millis() as u64, - failure_reason, - // One-shot execution — no state-aware lifecycle phase. - phase: "", - // One-shot execution — already correlated by AppSessionGuid, no - // cross-phase lifecycle to join. - correlation_vector: "", - }); + if invocation_can_emit(active) { + log_execution(&ExecutionEvent { + backend, + exit_code: response.exit_code, + outcome, + duration_ms: elapsed.as_millis() as u64, + failure_reason, + // One-shot execution — no state-aware lifecycle phase. + phase: "", + // One-shot execution — already correlated by AppSessionGuid, no + // cross-phase lifecycle to join. + correlation_vector: "", + }); + } // The presence of an error message signals an infrastructure error (as // opposed to a script that merely exited non-zero). We use it only as a // boolean signal — the message text itself is never emitted. - if failed && !response.error_message.is_empty() { + if failed && !response.error_message.is_empty() && invocation_can_emit(active) { log_error( TelemetryContext { backend, @@ -248,8 +317,22 @@ pub fn emit_completion( response.exit_code, ); } +} - shutdown(); +/// 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, +) { + emit_sdk_with_release(active, || { + emit_completion_event(active, containment, response, elapsed) + }); } /// Emit failure telemetry for an early-exit path that terminates **before** a @@ -258,42 +341,65 @@ 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) { - if !active { + if !invocation_can_emit(active) { return; } if already_emitted() { return; } + emit_early_exit_event(active, containment, reason); + shutdown(); +} +fn emit_early_exit_event(active: bool, containment: &ContainmentBackend, reason: FailureReason) { let backend = containment.wire_name(); - log_execution(&ExecutionEvent { - backend, - exit_code: 1, - outcome: "failure", - duration_ms: 0, - failure_reason: Some(reason), - // One-shot early-exit — no state-aware lifecycle phase. - phase: "", - // One-shot early-exit — no cross-phase lifecycle to correlate. - correlation_vector: "", - }); - - log_error( - TelemetryContext { + if invocation_can_emit(active) { + log_execution(&ExecutionEvent { backend, + exit_code: 1, + outcome: "failure", + duration_ms: 0, + failure_reason: Some(reason), + // One-shot early-exit — no state-aware lifecycle phase. phase: "", + // One-shot early-exit — no cross-phase lifecycle to correlate. correlation_vector: "", - }, - reason, - 1, - ); + }); + } + if invocation_can_emit(active) { + log_error( + TelemetryContext { + backend, + phase: "", + correlation_vector: "", + }, + 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) { + emit_sdk_with_release(active, || { + emit_early_exit_event(active, containment, reason) + }); +} + +fn emit_sdk_with_release(active: bool, emit: impl FnOnce()) { + if !active { + return; + } + if invocation_can_emit(active) { + emit(); + } shutdown(); } @@ -411,7 +517,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>, @@ -430,7 +536,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>, @@ -460,15 +566,19 @@ fn plan_crash<'a>( /// resolved attribution so the pure [`plan_crash`] mapping stays testable. fn emit_crash(ctx: TelemetryContext<'_>, exit_code: i32, reason: FailureReason) { let plan = plan_crash(ctx, exit_code, reason); - log_execution(&plan.execution); - log_error(ctx, plan.error, plan.exit_code); + if process_can_emit() { + log_execution(&plan.execution); + } + if process_can_emit() { + log_error(ctx, plan.error, plan.exit_code); + } } /// Emit crash telemetry from a global panic hook. /// /// 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`]. /// @@ -477,7 +587,7 @@ fn emit_crash(ctx: TelemetryContext<'_>, exit_code: i32, reason: FailureReason) /// where the OS reclaims the ETW registration at process exit. It also carries /// **no** panic message text, which can contain paths or other PII. pub fn emit_panic() { - if !emit_active() || already_emitted() { + if !process_can_emit() || already_emitted() { return; } let correlation_vector = process_correlation_vector(); @@ -497,7 +607,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`]. /// @@ -506,7 +616,7 @@ pub fn emit_panic() { /// tears the process down via `ExitProcess`, and the main thread may still be /// live. It is allocation-light and emits no free-form text. pub fn emit_cancellation() { - if !emit_active() || already_emitted() { + if !process_can_emit() || already_emitted() { return; } let correlation_vector = process_correlation_vector(); @@ -541,7 +651,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> { @@ -579,7 +689,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, @@ -618,10 +728,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 @@ -632,26 +742,97 @@ pub fn emit_state_aware( outcome: &Result, elapsed: Duration, ) { - if !active { + if !invocation_can_emit(active) { return; } if already_emitted() { return; } + emit_state_aware_event(active, ctx, outcome, elapsed); + shutdown(); +} +fn emit_state_aware_event( + active: bool, + ctx: TelemetryContext<'_>, + outcome: &Result, + elapsed: Duration, +) { let duration_ms = elapsed.as_millis() as u64; let plan = plan_state_aware(ctx, outcome, duration_ms); - log_execution(&plan.execution); - if let Some(reason) = plan.error { + if invocation_can_emit(active) { + log_execution(&plan.execution); + } + if let Some(reason) = plan.error.filter(|_| invocation_can_emit(active)) { log_error(ctx, reason, plan.execution.exit_code); } +} - shutdown(); +/// 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, +) { + emit_sdk_with_release(active, || { + emit_state_aware_event(active, ctx, outcome, elapsed) + }); +} + +#[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 +842,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 +958,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()); @@ -714,14 +1014,36 @@ mod tests { ); } + #[test] + fn live_authorization_gate_suppresses_and_then_allows_emission() { + let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + reset_for_test(); + events::test_sink::install(); + TEST_FORCE_ACTIVE.with(|active| active.set(true)); + TEST_AUTHORIZATION_OVERRIDE.with(|allowed| allowed.set(Some(false))); + set_process_context(&ContainmentBackend::IsolationSession); + + emit_panic(); + assert!(events::test_sink::take_executions().is_empty()); + assert!(events::test_sink::take_errors().is_empty()); + + TEST_AUTHORIZATION_OVERRIDE.with(|allowed| allowed.set(Some(true))); + emit_panic(); + assert_eq!(events::test_sink::take_executions().len(), 1); + assert_eq!(events::test_sink::take_errors().len(), 1); + + reset_for_test(); + } + #[test] fn emit_panic_active_captures_execution_and_error() { // Drive the real emit glue (globals read → active guard → paired write) // 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(); + TEST_AUTHORIZATION_OVERRIDE.with(|allowed| allowed.set(Some(true))); TEST_FORCE_ACTIVE.with(|f| f.set(true)); set_process_context(&ContainmentBackend::IsolationSession); set_process_phase("exec"); @@ -730,7 +1052,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); @@ -740,7 +1062,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); @@ -758,6 +1080,7 @@ mod tests { let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); reset_for_test(); events::test_sink::install(); + TEST_AUTHORIZATION_OVERRIDE.with(|allowed| allowed.set(Some(true))); TEST_FORCE_ACTIVE.with(|f| f.set(true)); set_process_context(&ContainmentBackend::IsolationSession); set_process_phase("start"); @@ -800,12 +1123,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(); @@ -816,7 +1139,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(); @@ -985,7 +1308,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); @@ -1053,7 +1376,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); @@ -1064,7 +1387,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); @@ -1132,8 +1455,9 @@ mod tests { let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); reset_for_test(); events::test_sink::install(); + TEST_AUTHORIZATION_OVERRIDE.with(|allowed| allowed.set(Some(true))); - // 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, @@ -1155,6 +1479,7 @@ mod tests { // Fresh slot for the error case (the emit above claimed it once). reset_for_test(); events::test_sink::install(); + TEST_AUTHORIZATION_OVERRIDE.with(|allowed| allowed.set(Some(true))); let err = Err(MxcError::policy_validation("bad policy")); emit_state_aware( true, @@ -1180,6 +1505,22 @@ mod tests { reset_for_test(); } + #[cfg(windows)] + #[test] + fn sdk_emit_releases_provider_when_live_authorization_closes() { + let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + reset_for_test(); + TEST_AUTHORIZATION_OVERRIDE.with(|allowed| allowed.set(Some(false))); + + assert!(mxc_telemetry::init(version(), MXC_CHANNEL)); + assert!(mxc_telemetry::is_active()); + + emit_sdk_with_release(true, || panic!("authorization must suppress the event")); + + assert!(!mxc_telemetry::is_active()); + reset_for_test(); + } + // Validates that the emit guard honors the *real* `mxc_telemetry` provider — // not just the `TEST_FORCE_ACTIVE` override — by registering the provider for // real (only possible on Windows) and asserting `emit_panic` captures without @@ -1190,6 +1531,7 @@ mod tests { let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); reset_for_test(); events::test_sink::install(); + TEST_AUTHORIZATION_OVERRIDE.with(|allowed| allowed.set(Some(true))); // Register the real ETW provider; on Windows this makes is_active() true. assert!( 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 55bcce8d6..6fcd40668 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,10 +113,182 @@ 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, } +/// Telemetry-consent maintenance request. +/// +/// This is a separate contract from [`MxcConfig`], even though executors use +/// the same JSON input loader for both. Its explicit `command` discriminator +/// lets the loader route maintenance without widening the execution schema. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[cfg_attr( + feature = "schema-gen", + schemars(title = "MXC Telemetry Consent Maintenance Request") +)] +#[serde( + rename_all = "camelCase", + deny_unknown_fields, + expecting = "a telemetry consent maintenance request" +)] +pub struct TelemetryConsentMaintenanceRequest { + /// Optional JSON Schema reference for editor validation. + #[serde(rename = "$schema")] + pub schema: Option, + /// Fixed maintenance discriminator. Must be `"telemetryConsent"`. + pub command: TelemetryConsentCommand, + /// Consent operation to perform. + pub action: TelemetryConsentAction, + /// Preferred BCP 47 locale for the canonical prompt. Unsupported locales + /// fall back to `en-US`. Used only by `request`. + pub locale: Option, +} + +/// Fixed telemetry-consent maintenance command discriminator. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum TelemetryConsentCommand { + TelemetryConsent, +} + +/// Telemetry-consent maintenance action. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum TelemetryConsentAction { + /// Present the canonical prompt when consent may be requested. + Request, + /// Idempotently persist denied consent. + Withdraw, + /// Read status without mutation. + Status, +} + +/// Typed result of a telemetry-consent maintenance action. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum TelemetryConsentResult { + Status, + PresentationRequired, + Granted, + Denied, + Dismissed, + Withdrawn, + AlreadyGranted, + PolicyBlocked, + PresentationUnavailable, + NotApplicable, +} + +/// Typed decision returned by a consent presenter. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum TelemetryConsentDecision { + Yes, + No, + Dismissed, +} + +/// Private, session-bound presenter response consumed by the same executor +/// process that emitted the challenge. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TelemetryConsentPresenterResponse { + pub challenge: String, + pub resource_version: u32, + pub decision: TelemetryConsentDecision, +} + +/// Stable consent-state strings used by maintenance responses. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "kebab-case")] +pub enum TelemetryConsentState { + Granted, + Denied, + Undetermined, + NotApplicable, +} + +/// Stable policy-state strings used by maintenance responses. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "kebab-case")] +pub enum TelemetryConsentPolicyState { + Unrestricted, + Allowed, + Blocked, + NotApplicable, +} + +/// Why persisted consent is not currently effective. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "kebab-case")] +pub enum TelemetryConsentStatusReason { + NoRecord, + StoreUnreadable, + StoreMalformed, + ConsentSchemaUnsupported, + PromptVersionMissing, + PromptVersionUnsupported, + PolicyBlocked, + PresentationUnavailable, + NotApplicable, +} + +/// One canonical prompt message. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TelemetryConsentMessage { + pub id: String, + pub text: String, +} + +/// Canonical prompt data supplied to an SDK presenter. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TelemetryConsentPrompt { + pub resource_version: u32, + pub locale: String, + pub title: TelemetryConsentMessage, + pub body: TelemetryConsentMessage, + pub affirmative_label: TelemetryConsentMessage, + pub negative_label: TelemetryConsentMessage, + pub learn_more_label: TelemetryConsentMessage, + pub learn_more_url: String, +} + +/// Typed telemetry-consent maintenance response. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TelemetryConsentMaintenanceResponse { + pub action: TelemetryConsentAction, + pub result: TelemetryConsentResult, + pub stored_state: TelemetryConsentState, + pub effective_state: TelemetryConsentState, + pub reason: Option, + pub policy: TelemetryConsentPolicyState, + pub needs_prompt: bool, + /// Present only during the private SDK presenter handshake. + pub prompt: Option, + /// Opaque, single-request challenge for the private SDK presenter + /// handshake. Never persisted. + pub challenge: Option, +} + /// State-aware lifecycle phase. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] @@ -467,17 +639,17 @@ 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")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] 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, } @@ -589,12 +761,18 @@ pub struct IsolationSessionProvisionPhase { /// re-exported below as `generate_config_schema_json`. #[cfg(feature = "schema-gen")] mod schema_gen { - use super::MxcConfig; + use super::{ + MxcConfig, TelemetryConsentMaintenanceRequest, TelemetryConsentMaintenanceResponse, + TelemetryConsentPresenterResponse, + }; + use schemars::JsonSchema; /// Canonical `$id` for the generated dev schema. Bump alongside the dev schema /// version/filename (see `schemas/schema-version.json`). const SCHEMA_ID: &str = "https://github.com/microsoft/mxc/schemas/dev/mxc-config.schema.0.8.0-dev.json"; + const TELEMETRY_CONSENT_SCHEMA_ID: &str = + "https://github.com/microsoft/mxc/schemas/dev/mxc-telemetry-consent.schema.1.json"; /// Generate the JSON Schema for the MXC config from the dedicated `MxcConfig` /// model. The schema is post-processed to (a) inject the canonical `$id`, @@ -618,13 +796,17 @@ mod schema_gen { /// JSON-schema renderer and the TypeScript emitter so both consume exactly the /// same model. fn schema_value() -> serde_json::Value { - let schema = schemars::schema_for!(MxcConfig); + schema_value_for::(SCHEMA_ID) + } + + fn schema_value_for(schema_id: &str) -> serde_json::Value { + let schema = schemars::schema_for!(T); let mut value = serde_json::to_value(&schema).expect("schema serialises to JSON value"); normalize_integer_formats(&mut value); if let serde_json::Value::Object(map) = &mut value { map.insert( "$id".to_string(), - serde_json::Value::String(SCHEMA_ID.to_string()), + serde_json::Value::String(schema_id.to_string()), ); } value @@ -642,6 +824,32 @@ mod schema_gen { crate::ts_emit::emit_ts(&value) } + /// Generate the separate telemetry-consent maintenance request schema. + pub fn generate_telemetry_consent_schema_json() -> String { + let value = + schema_value_for::(TELEMETRY_CONSENT_SCHEMA_ID); + if let serde_json::Value::Object(map) = &value { + return render_root_ordered(map); + } + serde_json::to_string_pretty(&value).expect("schema serialises to JSON") + } + + #[derive(JsonSchema)] + #[allow(dead_code)] + struct TelemetryConsentMaintenanceTypes { + request: TelemetryConsentMaintenanceRequest, + response: TelemetryConsentMaintenanceResponse, + presenter_response: TelemetryConsentPresenterResponse, + } + + /// Generate TypeScript wire types for both maintenance requests and + /// responses without adding them to the execution-config drift oracle. + pub fn generate_telemetry_consent_sdk_types_ts() -> String { + let value = + schema_value_for::(TELEMETRY_CONSENT_SCHEMA_ID); + crate::ts_emit::emit_ts(&value) + } + /// Render the root object as pretty JSON with a fixed key order — the schema /// metadata (`$schema`, `$id`, `title`, `description`) first, then the /// structural keys — without disturbing nested key order. @@ -724,4 +932,7 @@ mod schema_gen { } #[cfg(feature = "schema-gen")] -pub use schema_gen::{generate_config_schema_json, generate_sdk_types_ts}; +pub use schema_gen::{ + generate_config_schema_json, generate_sdk_types_ts, generate_telemetry_consent_schema_json, + generate_telemetry_consent_sdk_types_ts, +}; diff --git a/src/ffi/mxc_ffi/Cargo.toml b/src/ffi/mxc_ffi/Cargo.toml index 279af6b8b..8b4bd69d2 100644 --- a/src/ffi/mxc_ffi/Cargo.toml +++ b/src/ffi/mxc_ffi/Cargo.toml @@ -24,5 +24,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 f7fbb2c64..1b25d650b 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::ffi::{c_char, CStr, CString}; +use std::any::Any; +use std::ffi::{c_char, c_void, CStr, CString}; +use std::io::{self, Write}; use std::panic::catch_unwind; use std::ptr; use std::sync::OnceLock; @@ -56,6 +58,64 @@ mod streaming; pub use state_aware::*; pub use streaming::*; +/// Return code from an FFI telemetry-consent presenter callback. +pub const MXC_TELEMETRY_CONSENT_DECISION_NO: i32 = 0; +/// Return code from an FFI telemetry-consent presenter callback. +pub const MXC_TELEMETRY_CONSENT_DECISION_YES: i32 = 1; +/// Return code from an FFI telemetry-consent presenter callback. +pub const MXC_TELEMETRY_CONSENT_DECISION_DISMISSED: i32 = 2; +/// Return code indicating that the host presenter failed. +pub const MXC_TELEMETRY_CONSENT_PRESENTER_ERROR: i32 = -1; + +/// Host callback invoked synchronously with the canonical consent prompt JSON. +/// +/// The JSON pointer remains valid only for the duration of the callback. The +/// callback must return one of the `MXC_TELEMETRY_CONSENT_DECISION_*` values. +pub type MxcTelemetryConsentPresenter = + Option i32>; + +/// 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 +155,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 { @@ -214,6 +278,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 // --------------------------------------------------------------------------- @@ -238,8 +321,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. @@ -274,14 +359,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) { @@ -289,6 +369,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) => { @@ -339,10 +422,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. @@ -355,10 +440,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. @@ -373,6 +460,307 @@ 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. +// `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 +// 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(|| mxc_sdk::telemetry::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 +} + +fn consent_status_json( + status: mxc_sdk::telemetry::ConsentStatus, + policy: mxc_sdk::telemetry::PolicyState, +) -> serde_json::Value { + serde_json::json!({ + "storedState": status.stored_state.as_str(), + "effectiveState": status.effective_state.as_str(), + "reason": status.reason.map(|reason| reason.as_str()), + "policy": policy.as_str(), + }) +} + +fn consent_outcome_json(outcome: mxc_sdk::telemetry::ConsentActionOutcome) -> serde_json::Value { + let mut value = consent_status_json(outcome.status, outcome.policy); + value["result"] = serde_json::Value::String(outcome.result.as_str().to_string()); + value +} + +fn consent_prompt_json(prompt: &mxc_sdk::telemetry::ConsentPrompt) -> serde_json::Value { + fn message(value: mxc_sdk::telemetry::ConsentMessage) -> serde_json::Value { + serde_json::json!({ "id": value.id, "text": value.text }) + } + + serde_json::json!({ + "resourceVersion": prompt.resource_version, + "locale": prompt.locale, + "title": message(prompt.title), + "body": message(prompt.body), + "affirmativeLabel": message(prompt.affirmative_label), + "negativeLabel": message(prompt.negative_label), + "learnMoreLabel": message(prompt.learn_more_label), + "learnMoreUrl": prompt.learn_more_url, + }) +} + +unsafe fn write_json_out(value: serde_json::Value, out_utf8: *mut *mut c_char) -> i32 { + let bytes = match serde_json::to_vec(&value) { + Ok(bytes) => bytes, + Err(error) => { + report_to_stderr(format_args!( + "mxc: failed to serialize telemetry consent result: {error}" + )); + return MXC_STATUS_BACKEND_ERROR; + } + }; + // SAFETY: caller guarantees that the non-null out pointer is writable. + unsafe { ptr::write(out_utf8, alloc_cstring(&bytes)) }; + MXC_STATUS_SUCCESS +} + +/// Request telemetry consent through a host presenter callback. +/// +/// The callback receives the complete canonical prompt as JSON and returns a +/// typed decision synchronously. MXC persists a grant only when that callback +/// returns `MXC_TELEMETRY_CONSENT_DECISION_YES`. +/// +/// # Safety +/// `locale_utf8` must be null or valid NUL-terminated UTF-8. `presenter` must +/// be a valid callback when non-null. `context` is passed through untouched. +/// `out_utf8` must point to writable pointer-sized storage. +#[no_mangle] +pub unsafe extern "C" fn mxc_telemetry_request_consent( + locale_utf8: *const c_char, + presenter: MxcTelemetryConsentPresenter, + context: *mut c_void, + out_utf8: *mut *mut c_char, +) -> i32 { + if out_utf8.is_null() || presenter.is_none() { + return MXC_STATUS_NULL_ARGUMENT; + } + // SAFETY: caller contract above. + let locale = match unsafe { cstr_to_str(locale_utf8) } { + Some(value) => Some(value.to_string()), + None if locale_utf8.is_null() => None, + None => return MXC_STATUS_INVALID_UTF8, + }; + let presenter = presenter.expect("checked above"); + + let result = catch_unwind(|| { + mxc_sdk::telemetry::request_consent(locale.as_deref(), |prompt| { + let prompt_json = serde_json::to_vec(&consent_prompt_json(prompt)) + .map_err(|error| error.to_string())?; + let prompt_json = CString::new(prompt_json).map_err(|error| error.to_string())?; + // SAFETY: the host supplied this callback and context. The prompt + // pointer remains valid for the duration of this invocation. + let decision = unsafe { presenter(prompt_json.as_ptr(), context) }; + match decision { + MXC_TELEMETRY_CONSENT_DECISION_YES => Ok(mxc_sdk::telemetry::ConsentDecision::Yes), + MXC_TELEMETRY_CONSENT_DECISION_NO => Ok(mxc_sdk::telemetry::ConsentDecision::No), + MXC_TELEMETRY_CONSENT_DECISION_DISMISSED => { + Ok(mxc_sdk::telemetry::ConsentDecision::Dismissed) + } + MXC_TELEMETRY_CONSENT_PRESENTER_ERROR => { + Err("host consent presenter failed".to_string()) + } + value => Err(format!( + "host consent presenter returned invalid decision {value}" + )), + } + }) + }); + + match result { + Ok(Ok(outcome)) => { + // SAFETY: validated above. + unsafe { write_json_out(consent_outcome_json(outcome), out_utf8) } + } + Ok(Err(mxc_sdk::telemetry::ConsentError::Persist(error))) => { + report_to_stderr(format_args!( + "mxc: failed to persist telemetry consent: {error}" + )); + MXC_STATUS_CONSENT_WRITE_FAILED + } + Ok(Err(mxc_sdk::telemetry::ConsentError::Presenter(error))) => { + report_to_stderr(format_args!( + "mxc: telemetry consent presenter failed: {error}" + )); + MXC_STATUS_BACKEND_ERROR + } + Err(panic) => { + report_panic("mxc_telemetry_request_consent", &*panic); + MXC_STATUS_PANIC + } + } +} + +/// Persist an idempotent telemetry-consent withdrawal. +/// +/// # Safety +/// `out_utf8` must point to writable pointer-sized storage. +#[no_mangle] +pub unsafe extern "C" fn mxc_telemetry_withdraw_consent(out_utf8: *mut *mut c_char) -> i32 { + if out_utf8.is_null() { + return MXC_STATUS_NULL_ARGUMENT; + } + match catch_unwind(mxc_sdk::telemetry::withdraw_consent) { + Ok(Ok(outcome)) => { + // SAFETY: validated above. + unsafe { write_json_out(consent_outcome_json(outcome), out_utf8) } + } + Ok(Err(error)) => { + report_to_stderr(format_args!( + "mxc: failed to withdraw telemetry consent: {error}" + )); + MXC_STATUS_CONSENT_WRITE_FAILED + } + Err(panic) => { + report_panic("mxc_telemetry_withdraw_consent", &*panic); + MXC_STATUS_PANIC + } + } +} + +/// Return the typed persisted/effective consent and policy snapshot as JSON. +/// +/// # Safety +/// `out_utf8` must point to writable pointer-sized storage. +#[no_mangle] +pub unsafe extern "C" fn mxc_telemetry_get_consent_status(out_utf8: *mut *mut c_char) -> i32 { + if out_utf8.is_null() { + return MXC_STATUS_NULL_ARGUMENT; + } + let result = catch_unwind(|| { + consent_status_json( + mxc_sdk::telemetry::get_consent_status(), + mxc_sdk::telemetry::get_policy(), + ) + }); + match result { + Ok(value) => { + // SAFETY: validated above. + unsafe { write_json_out(value, out_utf8) } + } + Err(panic) => { + report_panic("mxc_telemetry_get_consent_status", &*panic); + 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(mxc_sdk::telemetry::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(|| mxc_sdk::telemetry::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::*; @@ -388,6 +776,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")); @@ -433,4 +839,360 @@ 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 presenter_request_then_get_consent_round_trips() { + let _guard = TelemetryTestEnv::new("round_trip"); + unsafe extern "C" fn presenter( + prompt_json_utf8: *const c_char, + context: *mut c_void, + ) -> i32 { + // SAFETY: the FFI request provides valid pointers for this call. + let prompt = unsafe { CStr::from_ptr(prompt_json_utf8) } + .to_str() + .unwrap(); + let prompt: serde_json::Value = serde_json::from_str(prompt).unwrap(); + let canonical = wxc_common::telemetry::consent_prompt::prompt_for_locale(Some("en-US")); + assert_eq!(prompt["resourceVersion"], canonical.resource_version); + assert_eq!(prompt["locale"], canonical.locale); + assert_eq!(prompt["title"]["id"], canonical.title.id); + assert_eq!(prompt["title"]["text"], canonical.title.text); + assert_eq!(prompt["body"]["id"], canonical.body.id); + assert_eq!(prompt["body"]["text"], canonical.body.text); + assert_eq!( + prompt["affirmativeLabel"]["text"], + canonical.affirmative_label.text + ); + assert_eq!( + prompt["negativeLabel"]["text"], + canonical.negative_label.text + ); + assert_eq!( + prompt["learnMoreLabel"]["text"], + canonical.learn_more_label.text + ); + assert_eq!(prompt["learnMoreUrl"], canonical.learn_more_url); + // SAFETY: the test passes a pointer to this bool as context. + unsafe { *(context as *mut bool) = true }; + MXC_TELEMETRY_CONSENT_DECISION_YES + } + + let mut called = false; + let mut outcome: *mut c_char = ptr::null_mut(); + // SAFETY: callback, context, and out pointer remain valid for the call. + let request_status = unsafe { + mxc_telemetry_request_consent( + ptr::null(), + Some(presenter), + (&mut called as *mut bool).cast(), + &mut outcome, + ) + }; + assert_eq!(request_status, MXC_STATUS_SUCCESS); + assert!(!outcome.is_null()); + // SAFETY: allocated by the request export. + let outcome_json = unsafe { CStr::from_ptr(outcome) }.to_str().unwrap(); + let outcome_json: serde_json::Value = serde_json::from_str(outcome_json).unwrap(); + unsafe { mxc_string_free(outcome) }; + + 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!(called); + assert_eq!(outcome_json["result"], "granted"); + assert_eq!(s, "granted"); + } + #[cfg(not(target_os = "windows"))] + { + assert!(!called); + assert_eq!(outcome_json["result"], "not-applicable"); + assert_eq!(s, "not-applicable"); + } + } + + #[cfg(debug_assertions)] + #[test] + fn request_consent_requires_a_presenter() { + let _guard = TelemetryTestEnv::new("null_presenter"); + let mut out: *mut c_char = ptr::null_mut(); + // SAFETY: null presenter is explicitly rejected. + let status = + unsafe { mxc_telemetry_request_consent(ptr::null(), None, ptr::null_mut(), &mut out) }; + assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); + assert!(out.is_null()); + } + + #[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")] + { + unsafe extern "C" fn deny( + _prompt_json_utf8: *const c_char, + _context: *mut c_void, + ) -> i32 { + MXC_TELEMETRY_CONSENT_DECISION_NO + } + let mut outcome: *mut c_char = ptr::null_mut(); + // SAFETY: callback and out pointer remain valid for the call. + assert_eq!( + unsafe { + mxc_telemetry_request_consent( + ptr::null(), + Some(deny), + ptr::null_mut(), + &mut outcome, + ) + }, + MXC_STATUS_SUCCESS + ); + // SAFETY: allocated by the request export. + unsafe { mxc_string_free(outcome) }; + // 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/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 a59dfefad..9e126d4d2 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); } } @@ -168,7 +171,7 @@ mod provider { "MXC.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), @@ -245,7 +248,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), ); } @@ -303,6 +306,23 @@ mod tests { /// `init`/`shutdown` must not run concurrently. static TEST_LOCK: Mutex<()> = Mutex::new(()); + #[test] + fn event_names_preserve_collector_contract() { + let source = include_str!("lib.rs"); + let provider_source = source + .split("// Tests") + .next() + .expect("provider source precedes tests"); + assert!( + provider_source.contains("\"MXC.Execution\","), + "execution event identity changed" + ); + assert!( + provider_source.contains("\"MXC.Error\","), + "error event identity changed" + ); + } + #[test] fn init_shutdown_roundtrip() { let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); @@ -318,9 +338,20 @@ mod tests { #[test] fn double_init_is_safe() { let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - let _ = init("0.0.0-test", "dev"); - let _ = init("0.0.0-test", "dev"); + let first = init("0.0.0-test", "dev"); + let second = init("0.0.0-test", "dev"); + assert_eq!(second, first); shutdown(); + assert_eq!( + is_active(), + first, + "one retained registration must remain active only when init succeeded" + ); + shutdown(); + assert!( + !is_active(), + "the final release must leave the provider inactive" + ); } #[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/src/tools/mxc_schema_gen/src/main.rs b/src/tools/mxc_schema_gen/src/main.rs index e6c098c15..0ac7e4d5f 100644 --- a/src/tools/mxc_schema_gen/src/main.rs +++ b/src/tools/mxc_schema_gen/src/main.rs @@ -1,13 +1,14 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Schema codegen tool. Emits the MXC config JSON Schema — or, with `--ts`, the -//! SDK wire TypeScript types — generated from the dedicated `wxc_common::wire` -//! model. +//! Schema codegen tool. Emits execution-config or telemetry-consent JSON +//! Schema/TypeScript artifacts generated from `wxc_common::wire`. //! //! Usage (run from the repo root; the Cargo workspace lives in `src/`): //! cargo run --manifest-path src/Cargo.toml -p mxc_schema_gen -- [output-path] //! cargo run --manifest-path src/Cargo.toml -p mxc_schema_gen -- --ts [output-path] +//! cargo run --manifest-path src/Cargo.toml -p mxc_schema_gen -- --telemetry-consent [output-path] +//! cargo run --manifest-path src/Cargo.toml -p mxc_schema_gen -- --telemetry-consent-ts [output-path] //! //! With no path the artifact is written to stdout. @@ -17,23 +18,43 @@ fn main() -> ExitCode { let mut args = std::env::args().skip(1); let first = args.next(); - let (emit_ts, path) = match first.as_deref() { - Some("--ts") => (true, args.next()), - Some(other) => (false, Some(other.to_string())), - None => (false, None), - }; + enum Artifact { + ConfigSchema, + ConfigTypes, + TelemetryConsentSchema, + TelemetryConsentTypes, + } - let content = if emit_ts { - wxc_common::wire::generate_sdk_types_ts() - } else { - // Preserve the historical schema output: the rendered string + trailing - // newline, byte-for-byte (the schema codegen gate diffs against it). - format!("{}\n", wxc_common::wire::generate_config_schema_json()) + let (artifact, path) = match first.as_deref() { + Some("--ts") => (Artifact::ConfigTypes, args.next()), + Some("--telemetry-consent") => (Artifact::TelemetryConsentSchema, args.next()), + Some("--telemetry-consent-ts") => (Artifact::TelemetryConsentTypes, args.next()), + Some(other) => (Artifact::ConfigSchema, Some(other.to_string())), + None => (Artifact::ConfigSchema, None), }; - let label = if emit_ts { - "SDK TypeScript types" - } else { - "generated schema" + + let (content, label) = match artifact { + Artifact::ConfigSchema => ( + // Preserve the historical schema output: the rendered string + + // trailing newline, byte-for-byte. + format!("{}\n", wxc_common::wire::generate_config_schema_json()), + "generated schema", + ), + Artifact::ConfigTypes => ( + wxc_common::wire::generate_sdk_types_ts(), + "SDK TypeScript types", + ), + Artifact::TelemetryConsentSchema => ( + format!( + "{}\n", + wxc_common::wire::generate_telemetry_consent_schema_json() + ), + "telemetry consent schema", + ), + Artifact::TelemetryConsentTypes => ( + wxc_common::wire::generate_telemetry_consent_sdk_types_ts(), + "telemetry consent TypeScript types", + ), }; match path { 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_consent_smoke_test.ps1 b/tests/scripts/run_telemetry_consent_smoke_test.ps1 new file mode 100644 index 000000000..e795585b2 --- /dev/null +++ b/tests/scripts/run_telemetry_consent_smoke_test.ps1 @@ -0,0 +1,164 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +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)) { + throw "Debug wxc-exec.exe not found at '$wxcExec'. Run 'cargo build -p wxc'." +} +if ($wxcExec -match '\\release\\') { + throw 'Release binaries are refused because consent isolation uses debug-only overrides.' +} +$expectedBody = @' +Help improve MXC by sharing optional diagnostic data with Microsoft. +If enabled, MXC sends diagnostic information about product usage, performance, and reliability. MXC does not send your commands, file paths, credentials, or other customer content. +You can change your choice at any time. +'@ -replace "`r`n", "`n" + +function ConvertTo-Base64Json([object]$Value) { + $json = $Value | ConvertTo-Json -Compress -Depth 10 + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($json)) +} + +function Invoke-Maintenance([string]$Action) { + $request = [pscustomobject]@{ command = 'telemetryConsent'; action = $Action } + $output = & $wxcExec --config-base64 (ConvertTo-Base64Json $request) + if ($LASTEXITCODE -ne 0) { throw "$Action failed with exit code $LASTEXITCODE" } + $output | ConvertFrom-Json +} + +function Invoke-ConsentRequest([string]$Decision) { + $request = [pscustomobject]@{ command = 'telemetryConsent'; action = 'request'; locale = 'en-US' } + $start = New-Object Diagnostics.ProcessStartInfo + $start.FileName = $wxcExec + $start.Arguments = "--config-base64 $(ConvertTo-Base64Json $request)" + $start.UseShellExecute = $false + $start.RedirectStandardInput = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $start.CreateNoWindow = $true + $start.Environment['MXC_TELEMETRY_CONSENT_PRESENTER_PROTOCOL'] = '1' + $process = New-Object Diagnostics.Process + $process.StartInfo = $start + if (-not $process.Start()) { throw 'Failed to start consent process.' } + $firstLine = $process.StandardOutput.ReadLine() + $first = $firstLine | ConvertFrom-Json + if ($first.result -ne 'presentationRequired') { + $process.WaitForExit() + return $first + } + if (-not $first.prompt -or -not $first.challenge) { + throw "Unexpected consent presentation: $firstLine" + } + if ($first.prompt.resourceVersion -ne 1 -or + $first.prompt.locale -ne 'en-US' -or + $first.prompt.title.text -ne 'Help improve Microsoft eXecution Container (MXC)' -or + $first.prompt.body.text -ne $expectedBody -or + $first.prompt.affirmativeLabel.text -ne 'Yes' -or + $first.prompt.negativeLabel.text -ne 'No' -or + $first.prompt.learnMoreLabel.text -ne 'Privacy Statement' -or + $first.prompt.learnMoreUrl -ne 'https://go.microsoft.com/fwlink/?linkid=521839') { + throw 'The canonical consent resource drifted.' + } + $response = [pscustomobject]@{ + challenge = $first.challenge + resourceVersion = $first.prompt.resourceVersion + decision = $Decision + } + $process.StandardInput.WriteLine(($response | ConvertTo-Json -Compress)) + $process.StandardInput.Close() + $finalLine = $process.StandardOutput.ReadLine() + $stderr = $process.StandardError.ReadToEnd() + $process.WaitForExit() + if ($process.ExitCode -ne 0) { throw "Consent process failed: $stderr" } + $finalLine | ConvertFrom-Json +} + +function Assert-Status( + [object]$Value, + [string]$Stored, + [string]$Effective, + [string]$Policy, + [bool]$NeedsPrompt, + [string]$Step +) { + if ($Value.storedState -ne $Stored -or + $Value.effectiveState -ne $Effective -or + $Value.policy -ne $Policy -or + $Value.needsPrompt -ne $NeedsPrompt) { + throw "$Step returned an unexpected response: $($Value | ConvertTo-Json -Compress)" + } +} + +$overrideName = 'MXC_TEST_LOCALAPPDATA_OVERRIDE' +$policyOverrideName = 'MXC_TEST_POLICY_KEY_OVERRIDE' +$originalOverride = [Environment]::GetEnvironmentVariable($overrideName) +$originalPolicyOverride = [Environment]::GetEnvironmentVariable($policyOverrideName) +$runId = [guid]::NewGuid().ToString('N') +$tempDir = Join-Path ([IO.Path]::GetTempPath()) "mxc_consent_smoke_$runId" +$consentFile = Join-Path $tempDir 'mxc\telemetry-consent.json' +$policySubkey = "Software\MxcTelemetryConsentSmoke\$runId" +$policyPath = "HKCU:\$policySubkey" + +try { + New-Item -ItemType Directory -Path (Split-Path -Parent $consentFile) -Force | Out-Null + New-Item -Path $policyPath -Force | Out-Null + Set-Item "Env:$overrideName" $tempDir + Set-Item "Env:$policyOverrideName" $policySubkey + + foreach ($seed in @('granted', 'denied')) { + $record = [pscustomobject]@{ + schemaVersion = 2 + consent = $seed + source = 'smoke-proof' + promptedMxcVersion = '0.0.0-smoke' + promptResourceVersion = 1 + promptLocale = 'en-US' + updatedAtEpoch = 0 + } + [IO.File]::WriteAllText( + $consentFile, + ($record | ConvertTo-Json -Compress), + (New-Object Text.UTF8Encoding $false)) + $probe = Invoke-Maintenance 'status' + if ($probe.effectiveState -ne $seed) { + throw "Consent override proof failed for '$seed'; refusing all MXC writes." + } + } + Remove-Item $consentFile -Force + + $fresh = Invoke-Maintenance 'status' + Assert-Status $fresh 'undetermined' 'undetermined' 'unrestricted' $true 'fresh status' + + $denied = Invoke-ConsentRequest 'no' + if ($denied.result -ne 'denied') { throw 'Explicit No was not persisted as Denied.' } + Assert-Status (Invoke-Maintenance 'status') 'denied' 'denied' 'unrestricted' $false 'denied status' + + $granted = Invoke-ConsentRequest 'yes' + if ($granted.result -ne 'granted') { throw 'Explicit Yes was not persisted as Granted.' } + Assert-Status (Invoke-Maintenance 'status') 'granted' 'granted' 'unrestricted' $false 'granted status' + + Set-ItemProperty $policyPath -Name AllowTelemetry -Value 0 -Type DWord + $blocked = Invoke-ConsentRequest 'yes' + if ($blocked.result -ne 'policyBlocked') { throw 'Blocked policy did not suppress presentation.' } + Assert-Status $blocked 'granted' 'granted' 'blocked' $false 'blocked status' + + $withdrawn = Invoke-Maintenance 'withdraw' + if ($withdrawn.result -ne 'withdrawn') { throw 'Withdrawal did not report withdrawn.' } + Assert-Status $withdrawn 'denied' 'denied' 'blocked' $false 'withdrawal while blocked' + $withdrawnAgain = Invoke-Maintenance 'withdraw' + if ($withdrawnAgain.result -ne 'withdrawn') { throw 'Repeated withdrawal was not idempotent.' } + + Write-Host 'PASSED: isolated telemetry consent smoke test' -ForegroundColor Green +} finally { + if ($null -eq $originalOverride) { Remove-Item "Env:$overrideName" -ErrorAction SilentlyContinue } + else { Set-Item "Env:$overrideName" $originalOverride } + if ($null -eq $originalPolicyOverride) { Remove-Item "Env:$policyOverrideName" -ErrorAction SilentlyContinue } + else { Set-Item "Env:$policyOverrideName" $originalPolicyOverride } + Remove-Item -Recurse -Force $tempDir -ErrorAction SilentlyContinue + Remove-Item -Recurse -Force $policyPath -ErrorAction SilentlyContinue +} diff --git a/tests/scripts/run_telemetry_etw_smoke_test.ps1 b/tests/scripts/run_telemetry_etw_smoke_test.ps1 index 7c0243948..5f8828206 100644 --- a/tests/scripts/run_telemetry_etw_smoke_test.ps1 +++ b/tests/scripts/run_telemetry_etw_smoke_test.ps1 @@ -1,213 +1,219 @@ <# .SYNOPSIS - ETW capture smoke test for MXC telemetry. + Isolated ETW capture smoke test for MXC telemetry. .DESCRIPTION - Starts an ETW trace session targeting the MXC public provider GUID, - runs wxc-exec with telemetry enabled, stops the session, and verifies - that at least one event was captured. - - This test uses the PUBLIC provider GUID (already in the open-source - code) — it does NOT depend on or reveal the private telemetry group GUID. - - Requires: Administrator privileges (for ETW session creation), - wxc-exec.exe built, logman.exe (ships with Windows). - - Run from the repo root. + Uses a debug wxc-exec only, proves the debug-only consent and policy + redirects before any MXC write, grants consent through the canonical + session-bound presenter protocol, captures the public MXC ETW provider, + and restores all process/machine state in finally. #> [CmdletBinding()] param( + [string]$BinDir, [switch]$SkipClean ) $ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$srcDir = Join-Path $repoRoot 'src' +if (-not $BinDir) { + $BinDir = Join-Path $srcDir 'target\debug' +} +$wxcExe = Join-Path $BinDir 'wxc-exec.exe' +if (-not (Test-Path $wxcExe)) { + throw "Debug wxc-exec.exe not found at '$wxcExe'. Run 'cargo build -p wxc' (without --release)." +} +if ($wxcExe -match '\\release\\') { + throw 'Release binaries are refused: telemetry smoke isolation depends on debug-only test overrides.' +} +$expectedBody = @' +Help improve MXC by sharing optional diagnostic data with Microsoft. +If enabled, MXC sends diagnostic information about product usage, performance, and reliability. MXC does not send your commands, file paths, credentials, or other customer content. +You can change your choice at any time. +'@ -replace "`r`n", "`n" -# MXC public provider name. The provider GUID is derived deterministically from -# this name by `tracelogging::define_provider!` using the standard ETW name-hash -# algorithm (the same algorithm used by , WIL's -# IMPLEMENT_TRACELOGGING_CLASS, and .NET's EventSource). We compute the GUID from -# the name here rather than hard-coding a literal, so the test stays in lockstep -# with the provider name and never embeds a magic constant. -$providerName = 'Microsoft.MXC' +$configFile = Join-Path $repoRoot 'tests\examples\28_telemetry_enabled.json' +if (-not (Test-Path $configFile)) { + throw "Config not found: $configFile" +} +$providerName = 'Microsoft.MXC' function Get-TraceLoggingProviderGuid { param([Parameter(Mandatory)][string]$Name) - - # EventSource/TraceLogging name->GUID: SHA1 over a fixed namespace seed - # followed by the UTF-16BE bytes of the upper-cased name; first 16 bytes of - # the digest become the GUID with the version nibble forced to 5. - $seed = [byte[]]@( - 0x48, 0x2C, 0x2D, 0xB2, 0xC3, 0x90, 0x47, 0xC8, - 0x87, 0xF8, 0x1A, 0x15, 0xBF, 0xC1, 0x30, 0xFB - ) + $seed = [byte[]]@(0x48,0x2C,0x2D,0xB2,0xC3,0x90,0x47,0xC8,0x87,0xF8,0x1A,0x15,0xBF,0xC1,0x30,0xFB) $nameBytes = [System.Text.Encoding]::BigEndianUnicode.GetBytes($Name.ToUpperInvariant()) $buffer = New-Object byte[] ($seed.Length + $nameBytes.Length) [Array]::Copy($seed, 0, $buffer, 0, $seed.Length) [Array]::Copy($nameBytes, 0, $buffer, $seed.Length, $nameBytes.Length) - $sha1 = [System.Security.Cryptography.SHA1]::Create() - try { - $hash = $sha1.ComputeHash($buffer) - } finally { - $sha1.Dispose() - } - + try { $hash = $sha1.ComputeHash($buffer) } finally { $sha1.Dispose() } $guidBytes = New-Object byte[] 16 [Array]::Copy($hash, 0, $guidBytes, 0, 16) $guidBytes[7] = ($guidBytes[7] -band 0x0F) -bor 0x50 return '{' + ([guid]::new($guidBytes)).ToString() + '}' } -$providerGuid = Get-TraceLoggingProviderGuid -Name $providerName -$sessionName = 'MxcTelemetryTest' -$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +function ConvertTo-Base64Json { + param([Parameter(Mandatory)][object]$Value) + $json = $Value | ConvertTo-Json -Compress -Depth 10 + return [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($json)) +} + +function Invoke-Status { + $output = & $wxcExe --telemetry-consent-status + if ($LASTEXITCODE -ne 0) { throw "Consent status failed with exit code $LASTEXITCODE" } + return ($output | ConvertFrom-Json) +} + +function Invoke-CanonicalGrant { + $request = [pscustomobject]@{ command = 'telemetryConsent'; action = 'request'; locale = 'en-US' } + $start = New-Object Diagnostics.ProcessStartInfo + $start.FileName = $wxcExe + $start.Arguments = "--config-base64 $(ConvertTo-Base64Json $request)" + $start.UseShellExecute = $false + $start.RedirectStandardInput = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $start.CreateNoWindow = $true + $start.Environment['MXC_TELEMETRY_CONSENT_PRESENTER_PROTOCOL'] = '1' + $process = New-Object Diagnostics.Process + $process.StartInfo = $start + if (-not $process.Start()) { throw 'Failed to start consent maintenance process.' } + + $presentationLine = $process.StandardOutput.ReadLine() + if (-not $presentationLine) { throw "Consent process emitted no presentation: $($process.StandardError.ReadToEnd())" } + $presentation = $presentationLine | ConvertFrom-Json + if ($presentation.result -eq 'alreadyGranted') { + $process.WaitForExit() + return $presentation + } + if ($presentation.result -ne 'presentationRequired' -or -not $presentation.challenge -or -not $presentation.prompt) { + throw "Unexpected consent presentation: $presentationLine" + } + if ($presentation.prompt.resourceVersion -ne 1 -or + $presentation.prompt.locale -ne 'en-US' -or + $presentation.prompt.title.text -ne 'Help improve Microsoft eXecution Container (MXC)' -or + $presentation.prompt.body.text -ne $expectedBody -or + $presentation.prompt.affirmativeLabel.text -ne 'Yes' -or + $presentation.prompt.negativeLabel.text -ne 'No' -or + $presentation.prompt.learnMoreLabel.text -ne 'Privacy Statement' -or + $presentation.prompt.learnMoreUrl -ne 'https://go.microsoft.com/fwlink/?linkid=521839') { + throw 'The executor did not present the canonical telemetry consent resource.' + } -Write-Host "=== MXC ETW Capture Smoke Test ===" -ForegroundColor Cyan -Write-Host "Provider: $providerName $providerGuid" + $response = [pscustomobject]@{ + challenge = $presentation.challenge + resourceVersion = $presentation.prompt.resourceVersion + decision = 'yes' + } + $process.StandardInput.WriteLine(($response | ConvertTo-Json -Compress)) + $process.StandardInput.Close() + $finalLine = $process.StandardOutput.ReadLine() + $stderr = $process.StandardError.ReadToEnd() + $process.WaitForExit() + if ($process.ExitCode -ne 0) { throw "Consent request failed: $stderr" } + if (-not $finalLine) { throw 'Consent process emitted no final response.' } + $final = $finalLine | ConvertFrom-Json + if ($final.result -ne 'granted' -or $final.effectiveState -ne 'granted') { + throw "Consent was not granted: $finalLine" + } + return $final +} -# --------------------------------------------------------------------------- -# Pre-flight: elevation check -# --------------------------------------------------------------------------- $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = New-Object Security.Principal.WindowsPrincipal($identity) if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { - Write-Host "SKIPPED: this test requires Administrator privileges for ETW session creation." -ForegroundColor Yellow - exit 0 -} - -# --------------------------------------------------------------------------- -# Pre-flight: locate wxc-exec.exe -# --------------------------------------------------------------------------- -$srcDir = Join-Path $repoRoot 'src' -$candidates = @( - (Join-Path $srcDir 'target\debug\wxc-exec.exe'), - (Join-Path $srcDir 'target\release\wxc-exec.exe'), - (Join-Path $srcDir 'target\x86_64-pc-windows-msvc\debug\wxc-exec.exe'), - (Join-Path $srcDir 'target\x86_64-pc-windows-msvc\release\wxc-exec.exe'), - (Join-Path $srcDir 'target\aarch64-pc-windows-msvc\debug\wxc-exec.exe'), - (Join-Path $srcDir 'target\aarch64-pc-windows-msvc\release\wxc-exec.exe') -) -$wxcExe = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1 -if (-not $wxcExe) { - Write-Host "SKIPPED: wxc-exec.exe not found. Build first with build.bat." -ForegroundColor Yellow + Write-Host 'SKIPPED: Administrator privileges are required for ETW session creation.' -ForegroundColor Yellow exit 0 } -Write-Host "Using wxc-exec: $wxcExe" -# --------------------------------------------------------------------------- -# Pre-flight: locate telemetry example config -# --------------------------------------------------------------------------- -$configFile = Join-Path $repoRoot 'tests\examples\28_telemetry_enabled.json' -if (-not (Test-Path $configFile)) { - throw "Config not found: $configFile" -} - -# --------------------------------------------------------------------------- -# Setup: ETL output path -# --------------------------------------------------------------------------- -$etlDir = Join-Path $env:TEMP 'mxc_etw_test' -if (Test-Path $etlDir) { Remove-Item -Recurse -Force $etlDir } -New-Item -ItemType Directory -Path $etlDir -Force | Out-Null +$overrideName = 'MXC_TEST_LOCALAPPDATA_OVERRIDE' +$policyOverrideName = 'MXC_TEST_POLICY_KEY_OVERRIDE' +$originalOverride = [Environment]::GetEnvironmentVariable($overrideName) +$originalPolicyOverride = [Environment]::GetEnvironmentVariable($policyOverrideName) +$runId = [guid]::NewGuid().ToString('N') +$tempDir = Join-Path ([IO.Path]::GetTempPath()) "mxc_etw_test_$runId" +$consentFile = Join-Path $tempDir 'mxc\telemetry-consent.json' +$policySubkey = "Software\MxcTelemetryEtwSmoke\$runId" +$policyPath = "HKCU:\$policySubkey" +$etlDir = Join-Path $tempDir 'etl' $etlFile = Join-Path $etlDir 'mxc_trace.etl' - -# --------------------------------------------------------------------------- -# Step 1: Start ETW trace session -# --------------------------------------------------------------------------- -Write-Host "`n--- Starting ETW trace session '$sessionName' ---" -ForegroundColor Yellow - -# Remove any stale session from a previous interrupted run. -logman stop $sessionName -ets 2>$null | Out-Null -logman delete $sessionName -ets 2>$null | Out-Null - -logman create trace $sessionName -ets -o "$etlFile" -p $providerGuid 2>&1 | Out-Host -if ($LASTEXITCODE -ne 0) { - throw "Failed to create ETW trace session" -} -Write-Host "ETW session started, writing to $etlFile" - -# --------------------------------------------------------------------------- -# Step 2: Run wxc-exec with telemetry enabled -# --------------------------------------------------------------------------- -Write-Host "`n--- Running wxc-exec with telemetry ---" -ForegroundColor Yellow +$xmlFile = Join-Path $etlDir 'mxc_trace.xml' +$sessionName = "MxcTelemetryTest_$runId" +$traceStarted = $false try { - # Run with --experimental to enable the telemetry section. 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 ` - -PassThru -NoNewWindow -Wait - Write-Host "wxc-exec exited with code $($proc.ExitCode)" -} catch { - Write-Host "wxc-exec failed to run: $_" -ForegroundColor Yellow - # Continue — even a crash after init may have emitted events. -} - -# Brief pause for ETW buffers to flush. -Start-Sleep -Seconds 2 - -# --------------------------------------------------------------------------- -# Step 3: Stop ETW trace session -# --------------------------------------------------------------------------- -Write-Host "`n--- Stopping ETW trace session ---" -ForegroundColor Yellow -logman stop $sessionName -ets 2>&1 | Out-Host - -# --------------------------------------------------------------------------- -# Step 4: Validate captured events -# --------------------------------------------------------------------------- -Write-Host "`n--- Validating captured events ---" -ForegroundColor Yellow - -if (-not (Test-Path $etlFile)) { - throw "ETL file not found: $etlFile" -} + New-Item -ItemType Directory -Path (Split-Path -Parent $consentFile) -Force | Out-Null + New-Item -ItemType Directory -Path $etlDir -Force | Out-Null + New-Item -Path $policyPath -Force | Out-Null + Set-Item -Path "Env:$overrideName" -Value $tempDir + Set-Item -Path "Env:$policyOverrideName" -Value $policySubkey + + foreach ($seed in @('granted', 'denied')) { + $record = [pscustomobject]@{ + schemaVersion = 2 + consent = $seed + source = 'etw-smoke-proof' + promptedMxcVersion = '0.0.0-smoke' + promptResourceVersion = 1 + promptLocale = 'en-US' + updatedAtEpoch = 0 + } + [IO.File]::WriteAllText( + $consentFile, + ($record | ConvertTo-Json -Compress), + (New-Object Text.UTF8Encoding $false)) + $status = Invoke-Status + if ($status.effectiveState -ne $seed) { + throw "Consent override proof failed for '$seed'; refusing all MXC writes." + } + } + Remove-Item $consentFile -Force -$etlSize = (Get-Item $etlFile).Length -Write-Host "ETL file size: $etlSize bytes" + $grant = Invoke-CanonicalGrant + Set-ItemProperty -Path $policyPath -Name AllowTelemetry -Value 3 -Type DWord + $authorized = Invoke-Status + if ($authorized.effectiveState -ne 'granted' -or $authorized.policy -ne 'allowed') { + throw 'Consent/policy authorization was not effective before ETW capture.' + } -if ($etlSize -eq 0) { - Write-Host "FAILED: ETL file is empty — no events captured." -ForegroundColor Red - Write-Host "All prerequisites were met (admin, wxc-exec present, ETW session created)," -ForegroundColor Red - Write-Host "so the provider should have emitted at least one completion event." -ForegroundColor Red - exit 1 -} + $providerGuid = Get-TraceLoggingProviderGuid -Name $providerName + logman stop $sessionName -ets 2>$null | Out-Null + logman create trace $sessionName -ets -o "$etlFile" -p $providerGuid 2>&1 | Out-Host + if ($LASTEXITCODE -ne 0) { throw 'Failed to create ETW trace session.' } + $traceStarted = $true -# Convert .etl to XML for inspection. -$xmlFile = Join-Path $etlDir 'mxc_trace.xml' -tracerpt "$etlFile" -o "$xmlFile" -of XML -y 2>&1 | Out-Host - -if (-not (Test-Path $xmlFile)) { - throw "tracerpt failed to produce XML output" -} + $proc = Start-Process -FilePath $wxcExe -ArgumentList '--debug', $configFile -PassThru -NoNewWindow -Wait + Write-Host "wxc-exec exited with code $($proc.ExitCode)" + Start-Sleep -Seconds 2 -$xmlContent = Get-Content -Path $xmlFile -Raw -$eventCount = ([regex]::Matches($xmlContent, '&1 | Out-Host + $traceStarted = $false + if (-not (Test-Path $etlFile) -or (Get-Item $etlFile).Length -eq 0) { + throw 'ETL output was absent or empty.' } -} else { - Write-Host "`n=== ETW CAPTURE SMOKE TEST FAILED ===" -ForegroundColor Red - Write-Host "ETL file had content ($etlSize bytes) but no parseable events were found." -ForegroundColor Red - exit 1 + tracerpt "$etlFile" -o "$xmlFile" -of XML -lr -y 2>&1 | Out-Host + if (-not (Test-Path $xmlFile)) { throw 'tracerpt did not produce XML.' } + $xml = Get-Content $xmlFile -Raw + if (([regex]::Matches($xml, '$null | Out-Null } + if ($null -eq $originalOverride) { Remove-Item "Env:$overrideName" -ErrorAction SilentlyContinue } + else { Set-Item "Env:$overrideName" $originalOverride } + if ($null -eq $originalPolicyOverride) { Remove-Item "Env:$policyOverrideName" -ErrorAction SilentlyContinue } + else { Set-Item "Env:$policyOverrideName" $originalPolicyOverride } + Remove-Item -Recurse -Force $policyPath -ErrorAction SilentlyContinue + if (-not $SkipClean) { Remove-Item -Recurse -Force $tempDir -ErrorAction SilentlyContinue } } -# --------------------------------------------------------------------------- -# Cleanup -# --------------------------------------------------------------------------- -if (-not $SkipClean) { - Remove-Item -Recurse -Force $etlDir -ErrorAction SilentlyContinue -} +# Native cleanup commands may legitimately report "session not found." Do not +# leak their `$LASTEXITCODE` after the test has completed successfully. +exit 0