diff --git a/scripts/check-dotnet-api-parity.js b/scripts/check-dotnet-api-parity.js
index 68201386f..0dcf07950 100644
--- a/scripts/check-dotnet-api-parity.js
+++ b/scripts/check-dotnet-api-parity.js
@@ -206,22 +206,21 @@ function managedJsonFields(source, className) {
});
}
-// `legacyManagedOnly` lists managed JSON fields that intentionally have no Rust
-// counterpart because they are deprecated compatibility aliases. They must stay
-// serializable so legacy JSON round-trips, but the managed request path strips
-// them before the native layer sees them.
+// `managedOnly` lists managed JSON fields intentionally represented outside the
+// Rust policy builder. Compatibility aliases are stripped before native parsing;
+// telemetry is extracted by the FFI and applied through SandboxRequest.
function compareStructFields(
label,
rustSource,
rustName,
managedSource,
managedName,
- legacyManagedOnly = []
+ managedOnly = []
) {
compare(
`${label} fields`,
managedJsonFields(managedSource, managedName).filter(
- (field) => !legacyManagedOnly.includes(field)
+ (field) => !managedOnly.includes(field)
),
rustStructFields(rustSource, rustName)
);
@@ -312,12 +311,18 @@ for (const [
rustSource,
rustName,
managedName,
- legacyManagedOnly,
+ managedOnly,
] of [
// `captureDenials` is an obsolete managed-only alias (MXC0001, removed in
// 1.0). Rust only accepts it under `containment.captureDenials`, and
// MxcSandbox.PrepareRequest strips it from the policy before serialization.
- ["sandbox policy", rustPolicy, "SandboxPolicy", "SandboxPolicy", ["captureDenials"]],
+ [
+ "sandbox policy",
+ rustPolicy,
+ "SandboxPolicy",
+ "SandboxPolicy",
+ ["captureDenials", "telemetry"],
+ ],
["filesystem policy", rustPolicy, "FilesystemSection", "FilesystemPolicy"],
["UI policy", rustPolicy, "UiSection", "UiPolicy"],
["network policy", rustNetworkPolicy, "NetworkSection", "NetworkPolicy"],
@@ -334,7 +339,7 @@ for (const [
rustName,
managedPolicy,
managedName,
- legacyManagedOnly
+ managedOnly
);
}
const managedOneShot = [
diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/Microsoft.Mxc.Sdk.Tests.csproj b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/Microsoft.Mxc.Sdk.Tests.csproj
index 0c8048dc1..00aebe79a 100644
--- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/Microsoft.Mxc.Sdk.Tests.csproj
+++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/Microsoft.Mxc.Sdk.Tests.csproj
@@ -12,12 +12,53 @@
true
$(DefineConstants);MXC_WITH_WSLC
$(DefineConstants);MXC_WITH_ISOLATION_SESSION
+ $(MSBuildProjectDirectory)/../../../src
+ $(MxcSrcDir)/target/dotnet-test-support
+ --release
+ release
+ debug
+ test-support
+ $(MxcTestCargoFeatures) isolation_session
+ $(MxcTestCargoFeatures) wslc
+ mxc_ffi.dll
+ libmxc_ffi.dylib
+ libmxc_ffi.so
+
+
+
+
+
+
+
+
+ @(MxcTestRustcHostLine)
+
+
+
+ $(MxcTestRustcHostLine.Replace('host: ', '').Trim())
+ $(MxcTestCargoTargetDir)/$(MxcTestRustHostTriple)/$(MxcTestCargoProfileDir)/$(MxcTestNativeFileName)
+
+
+
+
+
+
+
diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs
index 91864d802..662f2ba8f 100644
--- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs
+++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs
@@ -827,6 +827,23 @@ public void SerializeRequest_StripsLegacyCaptureDenialsFromThePolicy()
.GetString());
}
+ [Fact]
+ public void SerializeRequest_PreservesTelemetryWhenMigratingLegacyCaptureDenials()
+ {
+ var policy = CreateLegacyCaptureDenialsPolicy(
+ new CaptureDenialsPolicy(),
+ "0.9.0-alpha");
+ policy.Telemetry = new TelemetrySettings { Enabled = true };
+ var request = new SandboxRequest(policy, "echo hi");
+
+ using var doc = JsonDocument.Parse(MxcSandbox.SerializeRequest(request));
+ var serializedPolicy = doc.RootElement.GetProperty("policy");
+
+ Assert.True(
+ serializedPolicy.GetProperty("telemetry").GetProperty("enabled").GetBoolean());
+ Assert.False(serializedPolicy.TryGetProperty("captureDenials", out _));
+ }
+
private static SandboxPolicy CreateLegacyCaptureDenialsPolicy(
CaptureDenialsPolicy captureDenials,
string version = "0.8.0-alpha")
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..399c0e5bc
--- /dev/null
+++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs
@@ -0,0 +1,788 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Collections.Concurrent;
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Runtime.Versioning;
+using Microsoft.Win32;
+using Xunit;
+
+namespace Microsoft.Mxc.Sdk.Tests;
+
+// These tests mutate process-global telemetry seams and Trace listeners.
+[CollectionDefinition("MxcTelemetry", DisableParallelization = true)]
+public sealed class MxcTelemetryCollectionDefinition
+{
+}
+
+[Collection("MxcTelemetry")]
+public sealed class MxcTelemetryTests
+{
+ private static readonly JsonSerializerOptions PolicyJsonOptions = new()
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
+ };
+
+ [Fact]
+ public void SandboxPolicy_TelemetrySerializesCanonically()
+ {
+ var policy = new SandboxPolicy
+ {
+ Version = SchemaVersions.MaximumSupported,
+ Telemetry = new TelemetrySettings { Enabled = true },
+ };
+
+ var json = JsonSerializer.Serialize(policy, PolicyJsonOptions);
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+
+ Assert.True(root.GetProperty("telemetry").GetProperty("enabled").GetBoolean());
+ }
+
+ [Fact]
+ public void GetPolicy_NeverThrowsAndFailsClosed()
+ {
+ var policy = MxcTelemetry.GetPolicy();
+ Assert.True(Enum.IsDefined(policy));
+ }
+
+ [Fact]
+ public void FailClosedDiagnostics_AreDeduplicatedAndDoNotExposeExceptionText()
+ {
+ var operation = $"TestGetPolicy{Guid.NewGuid():N}";
+ using var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting(
+ new MxcTelemetry.FailureCategoryTracker(capacity: 64));
+ using var output = new StringWriter();
+ using var listener = new TextWriterTraceListener(output);
+ var failure = new InvalidOperationException("sensitive\r\n\u001b[31mmessage");
+ Trace.Listeners.Add(listener);
+ try
+ {
+ MxcTelemetry.ReportFailClosed(operation, "Blocked", failure);
+ MxcTelemetry.ReportFailClosed(operation, "Blocked", failure);
+ MxcTelemetry.ReportFailClosed(operation, "false", ErrorCode.BackendError);
+ Trace.Flush();
+
+ var messages = output
+ .ToString()
+ .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries)
+ .Where(line => line.Contains(operation, StringComparison.Ordinal))
+ .ToArray();
+ Assert.Equal(2, messages.Length);
+ Assert.Contains(typeof(InvalidOperationException).FullName!, messages[0]);
+ Assert.Contains("HRESULT 0x", messages[0]);
+ Assert.DoesNotContain("sensitive", messages[0]);
+ Assert.DoesNotContain('\u001b', messages[0]);
+ Assert.Contains("BackendError", messages[1]);
+ }
+ finally
+ {
+ Trace.Listeners.Remove(listener);
+ }
+ }
+
+ [Fact]
+ public void FailClosedDiagnosticTracker_DeduplicatesAndEnforcesCapacity()
+ {
+ var tracker = new MxcTelemetry.FailureCategoryTracker(capacity: 2);
+
+ Assert.True(tracker.TryAdd("GetPolicy", "Blocked", "ExceptionA", 1));
+ Assert.False(tracker.TryAdd("GetPolicy", "Blocked", "ExceptionA", 1));
+ Assert.True(tracker.TryAdd("NeedsConsentPrompt", "false", "ErrorCode", 2));
+ Assert.False(tracker.TryAdd("GetConsent", "Undetermined", "ExceptionB", 3));
+ }
+
+ [Fact]
+ public void FailClosedDiagnosticTracker_DeduplicatesConcurrentReports()
+ {
+ var tracker = new MxcTelemetry.FailureCategoryTracker(capacity: 64);
+ var accepted = 0;
+
+ Parallel.For(0, 1_000, _ =>
+ {
+ if (tracker.TryAdd("GetPolicy", "Blocked", "ExceptionA", 1))
+ {
+ Interlocked.Increment(ref accepted);
+ }
+ });
+
+ Assert.Equal(1, accepted);
+ }
+
+ [Fact]
+ public void FailClosedDiagnosticTracker_EnforcesCapacityUnderConcurrentLoad()
+ {
+ var tracker = new MxcTelemetry.FailureCategoryTracker(capacity: 64);
+ var accepted = 0;
+
+ Parallel.For(0, 1_000, index =>
+ {
+ if (tracker.TryAdd("GetPolicy", "Blocked", "Exception", index))
+ {
+ Interlocked.Increment(ref accepted);
+ }
+ });
+
+ Assert.Equal(64, accepted);
+ }
+
+ [Fact]
+ public void FailClosedDiagnostics_DoNotPropagateTraceListenerFailures()
+ {
+ var operation = $"ThrowingTrace{Guid.NewGuid():N}";
+ using var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting(
+ new MxcTelemetry.FailureCategoryTracker(capacity: 64));
+ using var listener = new ThrowingTraceListener();
+ Trace.Listeners.Add(listener);
+ try
+ {
+ var exception = Record.Exception(() =>
+ MxcTelemetry.ReportFailClosed(
+ operation,
+ "Blocked",
+ ErrorCode.BackendError));
+ Assert.Null(exception);
+ }
+ finally
+ {
+ Trace.Listeners.Remove(listener);
+ }
+
+ using var output = new StringWriter();
+ using var capture = new TextWriterTraceListener(output);
+ Trace.Listeners.Add(capture);
+ try
+ {
+ MxcTelemetry.ReportFailClosed(operation, "Blocked", ErrorCode.BackendError);
+ Trace.Flush();
+ Assert.Contains(operation, output.ToString());
+ }
+ finally
+ {
+ Trace.Listeners.Remove(capture);
+ }
+ }
+
+ [Fact]
+ public void ReadOnlyQueryFailures_ReportAndReturnFailClosedValues()
+ {
+ var native = new FakeTelemetryQueryApi
+ {
+ GetConsentImpl = () => throw new DllNotFoundException("sensitive path"),
+ NeedsConsentPromptImpl = () => new((int)ErrorCode.BackendError, true),
+ GetPolicyImpl = () => throw new InvalidOperationException("sensitive policy"),
+ };
+ using var nativeScope = MxcTelemetry.OverrideTelemetryReadApiForTesting(native);
+ using var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting(
+ new MxcTelemetry.FailureCategoryTracker(capacity: 64));
+ using var output = new StringWriter();
+ using var listener = new TextWriterTraceListener(output);
+ Trace.Listeners.Add(listener);
+ try
+ {
+ Assert.Equal(TelemetryConsentState.Undetermined, MxcTelemetry.GetConsent());
+ Assert.False(MxcTelemetry.NeedsConsentPrompt());
+ Assert.Equal(TelemetryPolicyState.Blocked, MxcTelemetry.GetPolicy());
+ Trace.Flush();
+
+ var message = output.ToString();
+ Assert.Contains("GetConsent", message);
+ Assert.Contains("NeedsConsentPrompt", message);
+ Assert.Contains("GetPolicy", message);
+ Assert.DoesNotContain("sensitive", message);
+ }
+ finally
+ {
+ Trace.Listeners.Remove(listener);
+ }
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void ReadOnlyQueryFailures_CoverStatusAndExceptionPaths(bool throwException)
+ {
+ var native = new FakeTelemetryQueryApi
+ {
+ GetConsentImpl = () => throwException
+ ? throw new InvalidOperationException("consent")
+ : new((int)ErrorCode.BackendError, null),
+ NeedsConsentPromptImpl = () => throwException
+ ? throw new InvalidOperationException("prompt")
+ : new((int)ErrorCode.BackendError, true),
+ GetPolicyImpl = () => throwException
+ ? throw new InvalidOperationException("policy")
+ : new((int)ErrorCode.BackendError, null),
+ };
+ using var nativeScope = MxcTelemetry.OverrideTelemetryReadApiForTesting(native);
+ using var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting(
+ new MxcTelemetry.FailureCategoryTracker(capacity: 64));
+ using var output = new StringWriter();
+ using var listener = new TextWriterTraceListener(output);
+ Trace.Listeners.Add(listener);
+ try
+ {
+ if (throwException)
+ {
+ Assert.Equal(TelemetryConsentState.Undetermined, MxcTelemetry.GetConsent());
+ }
+ else
+ {
+ var exception = Assert.Throws(() => MxcTelemetry.GetConsent());
+ Assert.Equal(ErrorCode.BackendError, exception.Code);
+ }
+ Assert.False(MxcTelemetry.NeedsConsentPrompt());
+ Assert.Equal(TelemetryPolicyState.Blocked, MxcTelemetry.GetPolicy());
+ Trace.Flush();
+
+ var messages = output
+ .ToString()
+ .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries)
+ .Where(line => line.Contains("mxc:", StringComparison.Ordinal))
+ .ToArray();
+ Assert.Equal(throwException ? 3 : 2, messages.Length);
+ Assert.Contains(messages, line => line.Contains("NeedsConsentPrompt", StringComparison.Ordinal));
+ Assert.Contains(messages, line => line.Contains("GetPolicy", StringComparison.Ordinal));
+ Assert.All(
+ messages,
+ line => Assert.Contains(
+ throwException
+ ? typeof(InvalidOperationException).FullName!
+ : nameof(ErrorCode.BackendError),
+ line));
+ }
+ finally
+ {
+ Trace.Listeners.Remove(listener);
+ }
+ }
+
+ [Fact]
+ public void TelemetryQueryApiOverride_AllowsConcurrentDispose()
+ {
+ var calls = 0;
+ var native = new FakeTelemetryQueryApi
+ {
+ GetConsentImpl = () => new((int)ErrorCode.Success, "undetermined"),
+ NeedsConsentPromptImpl = () => new((int)ErrorCode.Success, false),
+ GetPolicyImpl = () =>
+ {
+ Interlocked.Increment(ref calls);
+ return new((int)ErrorCode.Success, "blocked");
+ },
+ };
+ var scope = MxcTelemetry.OverrideTelemetryReadApiForTesting(native);
+
+ Parallel.Invoke(scope.Dispose, scope.Dispose);
+
+ _ = MxcTelemetry.GetPolicy();
+ Assert.Equal(0, calls);
+ }
+
+ [Fact]
+ public void TestOverrides_RejectOverlapAndRestoreAfterConcurrentDispose()
+ {
+ var native = new FakeTelemetryQueryApi
+ {
+ GetConsentImpl = () => new((int)ErrorCode.Success, "undetermined"),
+ NeedsConsentPromptImpl = () => new((int)ErrorCode.Success, false),
+ GetPolicyImpl = () => new((int)ErrorCode.Success, "blocked"),
+ };
+ using var nativeScope = MxcTelemetry.OverrideTelemetryReadApiForTesting(native);
+ Assert.Throws(
+ () => MxcTelemetry.OverrideTelemetryReadApiForTesting(native));
+ Parallel.Invoke(nativeScope.Dispose, nativeScope.Dispose);
+
+ var tracker = new MxcTelemetry.FailureCategoryTracker(capacity: 64);
+ using var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting(tracker);
+ Assert.Throws(
+ () => MxcTelemetry.OverrideFailureCategoryTrackerForTesting(tracker));
+ Parallel.Invoke(trackerScope.Dispose, trackerScope.Dispose);
+
+ using var restoredNativeScope = MxcTelemetry.OverrideTelemetryReadApiForTesting(native);
+ using var restoredTrackerScope =
+ MxcTelemetry.OverrideFailureCategoryTrackerForTesting(tracker);
+ }
+
+ [Fact]
+ public void RequestConsent_IsNotApplicableWithoutInvokingPresenter_OffWindows()
+ {
+ if (OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ var called = false;
+ var outcome = MxcTelemetry.RequestConsent(_ =>
+ {
+ called = true;
+ return TelemetryConsentDecision.Yes;
+ });
+
+ Assert.False(called);
+ Assert.Equal(TelemetryConsentResult.NotApplicable, outcome.Result);
+ Assert.Equal(TelemetryPolicyState.NotApplicable, outcome.Policy);
+ }
+
+#if DEBUG
+ [Fact]
+ public void RequestConsent_PresenterExceptionReturnsBackendError_OnWindows()
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ using var _ = new TelemetryTestEnv();
+ var ex = Assert.Throws(() =>
+ MxcTelemetry.RequestConsent(_ => throw new InvalidOperationException("boom")));
+ Assert.Equal(ErrorCode.BackendError, ex.Code);
+ }
+
+ [Fact]
+ public async Task RequestConsentAsync_PreservesCallerSynchronizationContext_OnWindows()
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ using var _ = new TelemetryTestEnv();
+ using var context = new PumpSynchronizationContext();
+ var previousContext = SynchronizationContext.Current;
+ SynchronizationContext.SetSynchronizationContext(context);
+ try
+ {
+ var callerThread = Environment.CurrentManagedThreadId;
+ var request = MxcTelemetry.RequestConsentAsync(async _ =>
+ {
+ Assert.Same(context, SynchronizationContext.Current);
+ Assert.Equal(callerThread, Environment.CurrentManagedThreadId);
+ await Task.Yield();
+ Assert.Same(context, SynchronizationContext.Current);
+ Assert.Equal(callerThread, Environment.CurrentManagedThreadId);
+ return TelemetryConsentDecision.Yes;
+ }, cancellationToken: TestContext.Current.CancellationToken);
+
+ context.RunUntilCompleted(request);
+ Assert.Equal(TelemetryConsentResult.Granted, (await request).Result);
+ }
+ finally
+ {
+ SynchronizationContext.SetSynchronizationContext(previousContext);
+ }
+ }
+
+ [Fact]
+ public async Task RequestConsentAsync_CancellationAfterPresentationStartsDoesNotPersistDecision_OnWindows()
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ using var _ = new TelemetryTestEnv();
+ using var cancellation = new CancellationTokenSource();
+ var presenterStarted = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ var stalledPresenter = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+
+ var request = MxcTelemetry.RequestConsentAsync(_ =>
+ {
+ presenterStarted.SetResult();
+ return stalledPresenter.Task;
+ }, cancellationToken: cancellation.Token);
+
+ await presenterStarted.Task.WaitAsync(TestContext.Current.CancellationToken);
+ cancellation.Cancel();
+
+ var finished = await Task.WhenAny(
+ request,
+ Task.Delay(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken));
+ Assert.Same(request, finished);
+ await Assert.ThrowsAnyAsync(() => request);
+ Assert.Equal(
+ TelemetryConsentState.Undetermined,
+ MxcTelemetry.GetConsentStatus().StoredState);
+ }
+
+ [Fact]
+ public async Task RequestConsentAsync_ReportsPresenterFailureAfterCancellation_OnWindows()
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ using var _ = new TelemetryTestEnv();
+ using var cancellation = new CancellationTokenSource();
+ using var listener = new CapturingTraceListener(
+ "MXC telemetry consent presenter faulted after cancellation");
+ Trace.Listeners.Add(listener);
+ try
+ {
+ var presenterStarted = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ var presenter = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+
+ var request = MxcTelemetry.RequestConsentAsync(_ =>
+ {
+ presenterStarted.SetResult();
+ return presenter.Task;
+ }, cancellationToken: cancellation.Token);
+
+ await presenterStarted.Task.WaitAsync(TestContext.Current.CancellationToken);
+ cancellation.Cancel();
+ await Assert.ThrowsAnyAsync(() => request);
+
+ presenter.SetException(new InvalidOperationException("late presenter failure"));
+ var diagnostic = await listener.Message.WaitAsync(TestContext.Current.CancellationToken);
+ Assert.Contains("late presenter failure", diagnostic);
+ Assert.Equal(
+ TelemetryConsentState.Undetermined,
+ MxcTelemetry.GetConsentStatus().StoredState);
+ }
+ finally
+ {
+ Trace.Listeners.Remove(listener);
+ }
+ }
+
+ [Fact]
+ public async Task RequestConsentAsync_ExplicitDismissalCompletesWithoutCancellation_OnWindows()
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ using var _ = new TelemetryTestEnv();
+ var outcome = await MxcTelemetry.RequestConsentAsync(
+ _ => Task.FromResult(TelemetryConsentDecision.Dismissed),
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.Equal(TelemetryConsentResult.Dismissed, outcome.Result);
+ Assert.Equal(TelemetryConsentState.Undetermined, outcome.StoredState);
+ }
+
+ [Fact]
+ public async Task RequestConsentAsync_SynchronousPresenterCancellationDoesNotPersistDecision_OnWindows()
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ using var _ = new TelemetryTestEnv();
+ using var cancellation = new CancellationTokenSource();
+ var previousContext = SynchronizationContext.Current;
+ SynchronizationContext.SetSynchronizationContext(null);
+ try
+ {
+ var request = MxcTelemetry.RequestConsentAsync(_ =>
+ {
+ cancellation.Cancel();
+ cancellation.Token.ThrowIfCancellationRequested();
+ return Task.FromResult(TelemetryConsentDecision.Yes);
+ }, cancellationToken: cancellation.Token);
+
+ await Assert.ThrowsAnyAsync(() => request);
+ Assert.Equal(
+ TelemetryConsentState.Undetermined,
+ MxcTelemetry.GetConsentStatus().StoredState);
+ }
+ finally
+ {
+ SynchronizationContext.SetSynchronizationContext(previousContext);
+ }
+ }
+
+ [Fact]
+ public void FinalizeAsyncOutcome_PersistedDecisionWinsLateCancellation()
+ {
+ using var cancellation = new CancellationTokenSource();
+ cancellation.Cancel();
+ var outcome = new TelemetryConsentOutcome
+ {
+ Result = TelemetryConsentResult.Granted,
+ StoredState = TelemetryConsentState.Granted,
+ EffectiveState = TelemetryConsentState.Granted,
+ };
+
+ Assert.Same(outcome, MxcTelemetry.FinalizeAsyncOutcome(outcome, cancellation.Token));
+ }
+
+ [Fact]
+ public void FinalizeAsyncOutcome_CanceledDismissalThrows()
+ {
+ using var cancellation = new CancellationTokenSource();
+ cancellation.Cancel();
+ var outcome = new TelemetryConsentOutcome
+ {
+ Result = TelemetryConsentResult.Dismissed,
+ StoredState = TelemetryConsentState.Undetermined,
+ EffectiveState = TelemetryConsentState.Undetermined,
+ };
+
+ Assert.ThrowsAny(
+ () => MxcTelemetry.FinalizeAsyncOutcome(outcome, cancellation.Token));
+ }
+
+ [Fact]
+ public void ConsentLifecycle_RoundTripsThroughNativeApi_OnWindows()
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ using var _ = new TelemetryTestEnv();
+ Assert.Equal(TelemetryConsentState.Undetermined, MxcTelemetry.GetConsent());
+ Assert.True(MxcTelemetry.NeedsConsentPrompt());
+
+ var dismissed = MxcTelemetry.RequestConsent(_ => TelemetryConsentDecision.Dismissed);
+ Assert.Equal(TelemetryConsentResult.Dismissed, dismissed.Result);
+ Assert.Equal(TelemetryConsentState.Undetermined, dismissed.StoredState);
+
+ var granted = MxcTelemetry.RequestConsent(_ => TelemetryConsentDecision.Yes);
+ Assert.Equal(TelemetryConsentResult.Granted, granted.Result);
+ Assert.Equal(TelemetryConsentState.Granted, MxcTelemetry.GetConsent());
+ Assert.Equal(TelemetryConsentState.Granted, MxcTelemetry.GetConsentStatus().EffectiveState);
+ Assert.False(MxcTelemetry.NeedsConsentPrompt());
+
+ var withdrawn = MxcTelemetry.WithdrawConsent();
+ Assert.Equal(TelemetryConsentResult.Withdrawn, withdrawn.Result);
+ Assert.Equal(TelemetryConsentState.Denied, MxcTelemetry.GetConsent());
+ Assert.False(MxcTelemetry.NeedsConsentPrompt());
+ }
+
+ [SupportedOSPlatform("windows")]
+ private sealed class TelemetryTestEnv : IDisposable
+ {
+ private static readonly SemaphoreSlim Gate = new(1, 1);
+
+ private readonly string? _originalLocalAppData;
+ private readonly string? _originalLocalAppDataOwnerPid;
+ private readonly string? _originalPolicyKey;
+ private readonly string? _originalPolicyOwnerPid;
+ private readonly string _storeDir;
+ private readonly string _policySubkey;
+
+ public TelemetryTestEnv()
+ {
+ Gate.Wait();
+ try
+ {
+ _originalLocalAppData = Environment.GetEnvironmentVariable("MXC_TEST_LOCALAPPDATA_OVERRIDE");
+ _originalLocalAppDataOwnerPid = Environment.GetEnvironmentVariable(
+ "MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID");
+ _originalPolicyKey = Environment.GetEnvironmentVariable("MXC_TEST_POLICY_KEY_OVERRIDE");
+ _originalPolicyOwnerPid = Environment.GetEnvironmentVariable("MXC_TEST_POLICY_KEY_OVERRIDE_OWNER_PID");
+
+ _storeDir = Directory.CreateTempSubdirectory("mxc-dotnet-telemetry-").FullName;
+ _policySubkey = $@"Software\MxcTelemetryDotNetTests\{Guid.NewGuid():N}";
+
+ Registry.CurrentUser.CreateSubKey(_policySubkey)?.Dispose();
+ Environment.SetEnvironmentVariable("MXC_TEST_LOCALAPPDATA_OVERRIDE", _storeDir);
+ Environment.SetEnvironmentVariable(
+ "MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID",
+ GetParentProcessId().ToString());
+ Environment.SetEnvironmentVariable("MXC_TEST_POLICY_KEY_OVERRIDE", _policySubkey);
+ Environment.SetEnvironmentVariable(
+ "MXC_TEST_POLICY_KEY_OVERRIDE_OWNER_PID",
+ Environment.ProcessId.ToString());
+ }
+ catch
+ {
+ Gate.Release();
+ throw;
+ }
+ }
+
+ public void Dispose()
+ {
+ Environment.SetEnvironmentVariable("MXC_TEST_LOCALAPPDATA_OVERRIDE", _originalLocalAppData);
+ Environment.SetEnvironmentVariable(
+ "MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID",
+ _originalLocalAppDataOwnerPid);
+ Environment.SetEnvironmentVariable("MXC_TEST_POLICY_KEY_OVERRIDE", _originalPolicyKey);
+ Environment.SetEnvironmentVariable("MXC_TEST_POLICY_KEY_OVERRIDE_OWNER_PID", _originalPolicyOwnerPid);
+
+ try
+ {
+ if (Directory.Exists(_storeDir))
+ {
+ Directory.Delete(_storeDir, recursive: true);
+ }
+ }
+ catch
+ {
+ }
+
+ try
+ {
+ Registry.CurrentUser.DeleteSubKeyTree(_policySubkey, throwOnMissingSubKey: false);
+ }
+ catch
+ {
+ }
+
+ Gate.Release();
+ }
+
+ private static int GetParentProcessId()
+ {
+ using var process = Process.GetCurrentProcess();
+ var status = NtQueryInformationProcess(
+ process.Handle,
+ processInformationClass: 0,
+ out var processInformation,
+ Marshal.SizeOf(),
+ out _);
+ if (status != 0)
+ {
+ throw new InvalidOperationException(
+ $"NtQueryInformationProcess failed with NTSTATUS 0x{status:X8}");
+ }
+
+ return checked((int)processInformation.InheritedFromUniqueProcessId);
+ }
+
+ [DllImport("ntdll.dll", ExactSpelling = true)]
+ private static extern int NtQueryInformationProcess(
+ IntPtr processHandle,
+ int processInformationClass,
+ out ProcessBasicInformation processInformation,
+ int processInformationLength,
+ out int returnLength);
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct ProcessBasicInformation
+ {
+ internal IntPtr Reserved1;
+ internal IntPtr PebBaseAddress;
+ internal IntPtr Reserved2_0;
+ internal IntPtr Reserved2_1;
+ internal IntPtr UniqueProcessId;
+ internal IntPtr InheritedFromUniqueProcessId;
+ }
+ }
+
+ private sealed class PumpSynchronizationContext : SynchronizationContext, IDisposable
+ {
+ private readonly ConcurrentQueue<(SendOrPostCallback Callback, object? State)> _work = new();
+ private readonly AutoResetEvent _workAvailable = new(initialState: false);
+
+ public override void Post(SendOrPostCallback callback, object? state)
+ {
+ _work.Enqueue((callback, state));
+ _workAvailable.Set();
+ }
+
+ public void RunUntilCompleted(Task task)
+ {
+ while (!task.IsCompleted)
+ {
+ if (_work.TryDequeue(out var work))
+ {
+ work.Callback(work.State);
+ }
+ else
+ {
+ _workAvailable.WaitOne(TimeSpan.FromSeconds(5));
+ }
+ }
+ }
+
+ public void Dispose() => _workAvailable.Dispose();
+ }
+
+ private sealed class CapturingTraceListener(string messagePrefix) : TraceListener
+ {
+ private readonly TaskCompletionSource _message = new(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+
+ public Task Message => _message.Task;
+
+ public override void TraceEvent(
+ TraceEventCache? eventCache,
+ string? source,
+ TraceEventType eventType,
+ int id,
+ string? message)
+ {
+ Capture(eventType, message);
+ }
+
+ public override void TraceEvent(
+ TraceEventCache? eventCache,
+ string? source,
+ TraceEventType eventType,
+ int id,
+ string? format,
+ params object?[]? args)
+ {
+ Capture(
+ eventType,
+ args is null || args.Length == 0
+ ? format
+ : string.Format(format ?? string.Empty, args));
+ }
+
+ public override void Write(string? message)
+ {
+ }
+
+ public override void WriteLine(string? message)
+ {
+ }
+
+ private void Capture(TraceEventType eventType, string? message)
+ {
+ if (eventType == TraceEventType.Error &&
+ message?.StartsWith(messagePrefix, StringComparison.Ordinal) == true)
+ {
+ _message.TrySetResult(message);
+ }
+ }
+ }
+#endif
+
+ private sealed class FakeTelemetryQueryApi : MxcTelemetry.ITelemetryReadApi
+ {
+ internal required Func GetConsentImpl { get; init; }
+ internal required Func NeedsConsentPromptImpl { get; init; }
+ internal required Func GetPolicyImpl { get; init; }
+
+ public MxcTelemetry.NativePayloadResult GetConsent() => GetConsentImpl();
+
+ public MxcTelemetry.NativeBooleanResult NeedsConsentPrompt() =>
+ NeedsConsentPromptImpl();
+
+ public MxcTelemetry.NativePayloadResult GetPolicy() => GetPolicyImpl();
+ }
+
+ private sealed class ThrowingTraceListener : TraceListener
+ {
+ public override void TraceEvent(
+ TraceEventCache? eventCache,
+ string? source,
+ TraceEventType eventType,
+ int id,
+ string? format,
+ params object?[]? args) =>
+ throw new InvalidOperationException("listener failure");
+
+ public override void Write(string? message) =>
+ throw new InvalidOperationException("listener failure");
+
+ public override void WriteLine(string? message) =>
+ throw new InvalidOperationException("listener failure");
+ }
+}
diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/ErrorCode.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/ErrorCode.cs
index 6bbde7798..488abed94 100644
--- a/sdk/dotnet/Microsoft.Mxc.Sdk/ErrorCode.cs
+++ b/sdk/dotnet/Microsoft.Mxc.Sdk/ErrorCode.cs
@@ -58,4 +58,7 @@ public enum ErrorCode
/// The native side panicked and was caught at the boundary (FFI-local).
Panic = 102,
+
+ /// Telemetry consent could not be persisted (FFI-local).
+ ConsentWriteFailed = 103,
}
diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcSandbox.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcSandbox.cs
index 12810c042..146a94f38 100644
--- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcSandbox.cs
+++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcSandbox.cs
@@ -320,6 +320,7 @@ private static SandboxPolicy ClonePolicyWithoutCaptureDenials(SandboxPolicy poli
Network = policy.Network,
Ui = policy.Ui,
TimeoutMs = policy.TimeoutMs,
+ Telemetry = policy.Telemetry,
};
private static bool CaptureDenialsEqual(
diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs
new file mode 100644
index 000000000..e90fa8ffa
--- /dev/null
+++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs
@@ -0,0 +1,825 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Diagnostics;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+using System.Text;
+using System.Text.Json;
+using Microsoft.Mxc.Sdk.Native;
+
+namespace Microsoft.Mxc.Sdk;
+
+/// A stored or effective telemetry consent state.
+public enum TelemetryConsentState
+{
+ Granted,
+ Denied,
+ Undetermined,
+ NotApplicable,
+}
+
+/// The administrative telemetry policy for this machine.
+public enum TelemetryPolicyState
+{
+ Unrestricted,
+ Allowed,
+ Blocked,
+ NotApplicable,
+}
+
+/// The presenter decision returned to MXC.
+public enum TelemetryConsentDecision
+{
+ No,
+ Yes,
+ Dismissed,
+}
+
+/// Why stored consent is not currently effective.
+public enum TelemetryConsentStatusReason
+{
+ NoRecord,
+ StoreUnreadable,
+ StoreMalformed,
+ ConsentSchemaUnsupported,
+ PromptVersionMissing,
+ PromptVersionUnsupported,
+ NotApplicable,
+}
+
+/// Result of a consent request or withdrawal.
+public enum TelemetryConsentResult
+{
+ Granted,
+ Denied,
+ Dismissed,
+ Withdrawn,
+ AlreadyGranted,
+ PolicyBlocked,
+ NotApplicable,
+}
+
+/// One canonical consent message.
+public sealed class TelemetryConsentMessage
+{
+ public string Id { get; init; } = string.Empty;
+ public string Text { get; init; } = string.Empty;
+}
+
+/// The canonical consent prompt resource passed to a host presenter.
+public sealed class TelemetryConsentPrompt
+{
+ public uint ResourceVersion { get; init; }
+ public string Locale { get; init; } = string.Empty;
+ public TelemetryConsentMessage Title { get; init; } = new();
+ public TelemetryConsentMessage Body { get; init; } = new();
+ public TelemetryConsentMessage AffirmativeLabel { get; init; } = new();
+ public TelemetryConsentMessage NegativeLabel { get; init; } = new();
+ public TelemetryConsentMessage LearnMoreLabel { get; init; } = new();
+ public string LearnMoreUrl { get; init; } = string.Empty;
+}
+
+/// Stored and effective consent plus the current administrative policy.
+public sealed class TelemetryConsentStatus
+{
+ public TelemetryConsentState StoredState { get; init; }
+ public TelemetryConsentState EffectiveState { get; init; }
+ public TelemetryConsentStatusReason? Reason { get; init; }
+ public TelemetryPolicyState Policy { get; init; }
+}
+
+/// Telemetry consent action result with the resulting status snapshot.
+public sealed class TelemetryConsentOutcome
+{
+ public TelemetryConsentResult Result { get; init; }
+ public TelemetryConsentState StoredState { get; init; }
+ public TelemetryConsentState EffectiveState { get; init; }
+ public TelemetryConsentStatusReason? Reason { get; init; }
+ public TelemetryPolicyState Policy { get; init; }
+}
+
+/// Telemetry consent helpers over the native mxc_ffi surface.
+public static class MxcTelemetry
+{
+ private const int NativeConsentDecisionNo = 0;
+ private const int NativeConsentDecisionYes = 1;
+ private const int NativeConsentDecisionDismissed = 2;
+ private const int NativeConsentPresenterError = -1;
+ private static readonly FailureCategoryTracker DefaultFailureCategoryTracker = new(capacity: 64);
+ private static readonly ITelemetryReadApi DefaultTelemetryReadApi = new PInvokeTelemetryReadApi();
+ private static FailureCategoryTracker reportedFailures = DefaultFailureCategoryTracker;
+ private static ITelemetryReadApi telemetryReadApi = DefaultTelemetryReadApi;
+
+ internal readonly record struct NativePayloadResult(int Status, string? Payload);
+ internal readonly record struct NativeBooleanResult(int Status, bool Value);
+
+ // The public wrappers interpret these native status results according to
+ // their individual throw-or-fail-closed contracts.
+ internal interface ITelemetryReadApi
+ {
+ NativePayloadResult GetConsent();
+ NativeBooleanResult NeedsConsentPrompt();
+ NativePayloadResult GetPolicy();
+ }
+
+ private sealed class PInvokeTelemetryReadApi : ITelemetryReadApi
+ {
+ public unsafe NativePayloadResult GetConsent()
+ {
+ byte* value = null;
+ var status = NativeMethods.mxc_telemetry_get_consent(&value);
+ try
+ {
+ return new(status, ReadNativeUtf8(value));
+ }
+ finally
+ {
+ FreeNativeString(value);
+ }
+ }
+
+ public unsafe NativeBooleanResult NeedsConsentPrompt()
+ {
+ int needsPrompt = 0;
+ var status = NativeMethods.mxc_telemetry_needs_consent_prompt(&needsPrompt);
+ return new(status, needsPrompt != 0);
+ }
+
+ public unsafe NativePayloadResult GetPolicy()
+ {
+ byte* value = null;
+ var status = NativeMethods.mxc_telemetry_get_policy(&value);
+ try
+ {
+ return new(status, ReadNativeUtf8(value));
+ }
+ finally
+ {
+ FreeNativeString(value);
+ }
+ }
+ }
+
+ internal static IDisposable OverrideTelemetryReadApiForTesting(
+ ITelemetryReadApi replacement)
+ {
+ ArgumentNullException.ThrowIfNull(replacement);
+ if (!ReferenceEquals(
+ Interlocked.CompareExchange(
+ ref telemetryReadApi,
+ replacement,
+ DefaultTelemetryReadApi),
+ DefaultTelemetryReadApi))
+ {
+ throw new InvalidOperationException(
+ "A telemetry read API test override is already active.");
+ }
+ return new TelemetryReadApiScope(replacement);
+ }
+
+ internal static IDisposable OverrideFailureCategoryTrackerForTesting(
+ FailureCategoryTracker replacement)
+ {
+ ArgumentNullException.ThrowIfNull(replacement);
+ if (!ReferenceEquals(
+ Interlocked.CompareExchange(
+ ref reportedFailures,
+ replacement,
+ DefaultFailureCategoryTracker),
+ DefaultFailureCategoryTracker))
+ {
+ throw new InvalidOperationException(
+ "A failure category tracker test override is already active.");
+ }
+ return new FailureCategoryTrackerScope(replacement);
+ }
+
+ private sealed class TelemetryReadApiScope(ITelemetryReadApi replacement) : IDisposable
+ {
+ private int disposed;
+
+ public void Dispose()
+ {
+ if (Interlocked.Exchange(ref disposed, 1) != 0)
+ {
+ return;
+ }
+ Interlocked.CompareExchange(
+ ref telemetryReadApi,
+ DefaultTelemetryReadApi,
+ replacement);
+ }
+ }
+
+ private sealed class FailureCategoryTrackerScope(
+ FailureCategoryTracker replacement) : IDisposable
+ {
+ private int disposed;
+
+ public void Dispose()
+ {
+ if (Interlocked.Exchange(ref disposed, 1) != 0)
+ {
+ return;
+ }
+ Interlocked.CompareExchange(
+ ref reportedFailures,
+ DefaultFailureCategoryTracker,
+ replacement);
+ }
+ }
+
+ internal sealed class FailureCategoryTracker(int capacity)
+ {
+ private readonly HashSet categories = [];
+
+ private readonly record struct FailureCategory(
+ string Operation,
+ string SafeResult,
+ string Kind,
+ int Code);
+
+ internal bool TryAdd(string operation, string safeResult, string kind, int code)
+ {
+ var category = new FailureCategory(operation, safeResult, kind, code);
+ lock (categories)
+ {
+ if (categories.Contains(category) || categories.Count >= capacity)
+ {
+ return false;
+ }
+ categories.Add(category);
+ return true;
+ }
+ }
+
+ internal void Remove(string operation, string safeResult, string kind, int code)
+ {
+ lock (categories)
+ {
+ categories.Remove(new FailureCategory(operation, safeResult, kind, code));
+ }
+ }
+ }
+
+ private sealed class PresenterContext
+ {
+ public required Func Presenter { get; init; }
+ public CancellationToken CancellationToken { get; init; }
+ }
+
+ ///
+ /// Return the consent state currently effective for telemetry authorization.
+ /// Use to read the persisted decision.
+ /// Fail-closed native-load failures return .
+ ///
+ public static TelemetryConsentState GetConsent()
+ {
+ try
+ {
+ EnsureNativeInitialized();
+ var result = telemetryReadApi.GetConsent();
+ if (result.Status != (int)ErrorCode.Success)
+ {
+ throw new MxcException(
+ (ErrorCode)result.Status,
+ "retrieving telemetry consent failed");
+ }
+
+ return ParseConsentState(result.Payload ?? "undetermined");
+ }
+ catch (MxcException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ ReportFailClosed("GetConsent", "Undetermined", ex);
+ return TelemetryConsentState.Undetermined;
+ }
+ }
+
+ ///
+ /// Read stored and effective consent, plus the current administrative policy.
+ ///
+ public static TelemetryConsentStatus GetConsentStatus()
+ {
+ EnsureNativeInitialized();
+ unsafe
+ {
+ byte* value = null;
+ var status = NativeMethods.mxc_telemetry_get_consent_status(&value);
+ try
+ {
+ EnsureSuccess(
+ status,
+ "retrieving telemetry consent status failed",
+ ReadNativeUtf8(value));
+ return ParseConsentStatus(ReadRequiredJson(value, "telemetry consent status"));
+ }
+ finally
+ {
+ FreeNativeString(value);
+ }
+ }
+ }
+
+ ///
+ /// Invoke a host presenter, then persist its decision.
+ ///
+ public static TelemetryConsentOutcome RequestConsent(
+ Func presenter,
+ string? locale = null)
+ {
+ ArgumentNullException.ThrowIfNull(presenter);
+ return RequestConsentCore(presenter, locale, CancellationToken.None);
+ }
+
+ private static TelemetryConsentOutcome RequestConsentCore(
+ Func presenter,
+ string? locale,
+ CancellationToken cancellationToken)
+ {
+ EnsureNativeInitialized();
+
+ var localeBuf = locale is null ? null : ToNullTerminatedUtf8(locale);
+ var presenterContext = GCHandle.Alloc(new PresenterContext
+ {
+ Presenter = presenter,
+ CancellationToken = cancellationToken,
+ });
+ try
+ {
+ unsafe
+ {
+ fixed (byte* localePtr = localeBuf)
+ {
+ byte* value = null;
+ var status = NativeMethods.mxc_telemetry_request_consent(
+ localePtr,
+ &PresentConsentBridge,
+ (void*)GCHandle.ToIntPtr(presenterContext),
+ &value);
+ try
+ {
+ EnsureSuccess(
+ status,
+ "requesting telemetry consent failed",
+ ReadNativeUtf8(value));
+ return ParseConsentOutcome(ReadRequiredJson(value, "telemetry consent outcome"));
+ }
+ finally
+ {
+ FreeNativeString(value);
+ }
+ }
+ }
+ }
+ finally
+ {
+ presenterContext.Free();
+ }
+ }
+
+ ///
+ /// Asynchronous wrapper over .
+ /// The native API is synchronous and blocking, so the native work runs on the thread pool while
+ /// the presenter is dispatched to the caller's synchronization context when one is available.
+ /// Cancellation stops waiting for the presenter and prevents its decision from being accepted
+ /// when observed before native persistence. It does not cancel the presenter's underlying task.
+ ///
+ public static Task RequestConsentAsync(
+ Func> presenter,
+ string? locale = null,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(presenter);
+ var synchronizationContext = SynchronizationContext.Current;
+ return Task.Run(
+ () =>
+ {
+ var outcome = RequestConsentCore(
+ prompt => InvokePresenterWithCancellation(
+ presenter,
+ prompt,
+ synchronizationContext,
+ cancellationToken),
+ locale,
+ cancellationToken);
+ return FinalizeAsyncOutcome(outcome, cancellationToken);
+ },
+ cancellationToken);
+ }
+
+ internal static TelemetryConsentOutcome FinalizeAsyncOutcome(
+ TelemetryConsentOutcome outcome,
+ CancellationToken cancellationToken)
+ {
+ if (outcome.Result == TelemetryConsentResult.Dismissed)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ }
+ return outcome;
+ }
+
+ /// Persist an idempotent telemetry-consent withdrawal.
+ public static TelemetryConsentOutcome WithdrawConsent()
+ {
+ EnsureNativeInitialized();
+ unsafe
+ {
+ byte* value = null;
+ var status = NativeMethods.mxc_telemetry_withdraw_consent(&value);
+ try
+ {
+ EnsureSuccess(
+ status,
+ "withdrawing telemetry consent failed",
+ ReadNativeUtf8(value));
+ return ParseConsentOutcome(ReadRequiredJson(value, "telemetry consent outcome"));
+ }
+ finally
+ {
+ FreeNativeString(value);
+ }
+ }
+ }
+
+ ///
+ /// Whether a host should show its first-run consent prompt.
+ /// Fails closed to and never throws.
+ ///
+ public static bool NeedsConsentPrompt()
+ {
+ try
+ {
+ EnsureNativeInitialized();
+ var result = telemetryReadApi.NeedsConsentPrompt();
+ if (result.Status != (int)ErrorCode.Success)
+ {
+ ReportFailClosed(
+ "NeedsConsentPrompt",
+ "false",
+ (ErrorCode)result.Status);
+ return false;
+ }
+
+ return result.Value;
+ }
+ catch (Exception ex)
+ {
+ ReportFailClosed("NeedsConsentPrompt", "false", ex);
+ return false;
+ }
+ }
+
+ ///
+ /// Read the administrative telemetry policy.
+ /// Fails closed to and never throws.
+ ///
+ public static TelemetryPolicyState GetPolicy()
+ {
+ try
+ {
+ EnsureNativeInitialized();
+ var result = telemetryReadApi.GetPolicy();
+ if (result.Status != (int)ErrorCode.Success)
+ {
+ ReportFailClosed("GetPolicy", "Blocked", (ErrorCode)result.Status);
+ return TelemetryPolicyState.Blocked;
+ }
+
+ return ParsePolicyState(result.Payload ?? "blocked");
+ }
+ catch (Exception ex)
+ {
+ ReportFailClosed("GetPolicy", "Blocked", ex);
+ return TelemetryPolicyState.Blocked;
+ }
+ }
+
+ internal static void ReportFailClosed(
+ string operation,
+ string safeResult,
+ Exception exception)
+ {
+ var exceptionType = exception.GetType().FullName ?? exception.GetType().Name;
+ ReportFailClosedCore(
+ operation,
+ safeResult,
+ exceptionType,
+ exception.HResult,
+ errorCode: null);
+ }
+
+ internal static void ReportFailClosed(
+ string operation,
+ string safeResult,
+ ErrorCode errorCode)
+ {
+ ReportFailClosedCore(
+ operation,
+ safeResult,
+ nameof(ErrorCode),
+ (int)errorCode,
+ errorCode);
+ }
+
+ private static void ReportFailClosedCore(
+ string operation,
+ string safeResult,
+ string kind,
+ int code,
+ ErrorCode? errorCode)
+ {
+ var registered = false;
+ try
+ {
+ if (!reportedFailures.TryAdd(
+ operation,
+ safeResult,
+ kind,
+ code))
+ {
+ return;
+ }
+ registered = true;
+
+ var description = errorCode is { } nativeError
+ ? $"{nativeError} ({code})"
+ : $"{kind} (HRESULT 0x{code:X8})";
+ Trace.TraceError(
+ "mxc: {0} failed and is reporting '{1}' to stay fail-closed: {2}",
+ operation,
+ safeResult,
+ description);
+ }
+ catch
+ {
+ if (registered)
+ {
+ reportedFailures.Remove(
+ operation,
+ safeResult,
+ kind,
+ code);
+ }
+ // Diagnostics must not affect the fail-closed result.
+ }
+ }
+
+ [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
+ private static unsafe int PresentConsentBridge(byte* promptJsonUtf8, void* context)
+ {
+ try
+ {
+ var handle = GCHandle.FromIntPtr((IntPtr)context);
+ if (handle.Target is not PresenterContext presenterContext)
+ {
+ return NativeConsentPresenterError;
+ }
+
+ var prompt = ParseConsentPrompt(ReadRequiredJson(promptJsonUtf8, "telemetry consent prompt"));
+ var decision = presenterContext.Presenter(prompt);
+ if (presenterContext.CancellationToken.IsCancellationRequested)
+ {
+ return NativeConsentDecisionDismissed;
+ }
+
+ return decision switch
+ {
+ TelemetryConsentDecision.Yes => NativeConsentDecisionYes,
+ TelemetryConsentDecision.No => NativeConsentDecisionNo,
+ TelemetryConsentDecision.Dismissed => NativeConsentDecisionDismissed,
+ _ => NativeConsentPresenterError,
+ };
+ }
+ catch
+ {
+ return NativeConsentPresenterError;
+ }
+ }
+
+ private static TelemetryConsentDecision InvokePresenterWithCancellation(
+ Func> presenter,
+ TelemetryConsentPrompt prompt,
+ SynchronizationContext? synchronizationContext,
+ CancellationToken cancellationToken)
+ {
+ Task? presenterTask = null;
+ try
+ {
+ presenterTask = InvokePresenterAsync(presenter, prompt, synchronizationContext);
+ return presenterTask
+ .WaitAsync(cancellationToken)
+ .GetAwaiter()
+ .GetResult();
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ if (presenterTask is not null)
+ {
+ ObserveAbandonedPresenterFailure(presenterTask);
+ }
+ return TelemetryConsentDecision.Dismissed;
+ }
+ }
+
+ private static void ObserveAbandonedPresenterFailure(
+ Task presenterTask)
+ {
+ _ = presenterTask.ContinueWith(
+ static faulted =>
+ {
+ var exception = faulted.Exception;
+ if (exception is null)
+ {
+ return;
+ }
+
+ try
+ {
+ Trace.TraceError(
+ "MXC telemetry consent presenter faulted after cancellation: {0}",
+ exception.GetBaseException());
+ }
+ catch
+ {
+ // Diagnostic listeners must not fault an unobserved continuation.
+ }
+ },
+ CancellationToken.None,
+ TaskContinuationOptions.ExecuteSynchronously | TaskContinuationOptions.OnlyOnFaulted,
+ TaskScheduler.Default);
+ }
+
+ private static Task InvokePresenterAsync(
+ Func> presenter,
+ TelemetryConsentPrompt prompt,
+ SynchronizationContext? synchronizationContext)
+ {
+ if (synchronizationContext is null)
+ {
+ return presenter(prompt);
+ }
+
+ var completion = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ try
+ {
+ synchronizationContext.Post(
+ async _ =>
+ {
+ try
+ {
+ completion.SetResult(await presenter(prompt));
+ }
+ catch (Exception ex)
+ {
+ completion.SetException(ex);
+ }
+ },
+ null);
+ }
+ catch (Exception ex)
+ {
+ completion.SetException(ex);
+ }
+
+ return completion.Task;
+ }
+
+ private static void EnsureNativeInitialized() => NativeLibraryResolver.Initialize();
+
+ private static void EnsureSuccess(int status, string fallbackMessage, string? nativeMessage)
+ {
+ if (status == (int)ErrorCode.Success)
+ {
+ return;
+ }
+
+ throw new MxcException(
+ (ErrorCode)status,
+ string.IsNullOrWhiteSpace(nativeMessage) ? fallbackMessage : nativeMessage);
+ }
+
+ private static unsafe string ReadRequiredJson(byte* value, string description) =>
+ ReadNativeUtf8(value) ?? throw new MxcException(ErrorCode.BackendError, $"{description} was missing");
+
+ private static unsafe string? ReadNativeUtf8(byte* value) =>
+ value is null ? null : Marshal.PtrToStringUTF8((IntPtr)value);
+
+ private static unsafe void FreeNativeString(byte* value)
+ {
+ if (value is not null)
+ {
+ NativeMethods.mxc_string_free(value);
+ }
+ }
+
+ private static byte[] ToNullTerminatedUtf8(string value)
+ {
+ var byteCount = Encoding.UTF8.GetByteCount(value);
+ var buffer = new byte[byteCount + 1];
+ Encoding.UTF8.GetBytes(value, 0, value.Length, buffer, 0);
+ buffer[byteCount] = 0;
+ return buffer;
+ }
+
+ private static TelemetryConsentPrompt ParseConsentPrompt(string json)
+ {
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+ return new TelemetryConsentPrompt
+ {
+ ResourceVersion = root.GetProperty("resourceVersion").GetUInt32(),
+ Locale = root.GetProperty("locale").GetString() ?? string.Empty,
+ Title = ParseConsentMessage(root.GetProperty("title")),
+ Body = ParseConsentMessage(root.GetProperty("body")),
+ AffirmativeLabel = ParseConsentMessage(root.GetProperty("affirmativeLabel")),
+ NegativeLabel = ParseConsentMessage(root.GetProperty("negativeLabel")),
+ LearnMoreLabel = ParseConsentMessage(root.GetProperty("learnMoreLabel")),
+ LearnMoreUrl = root.GetProperty("learnMoreUrl").GetString() ?? string.Empty,
+ };
+ }
+
+ private static TelemetryConsentMessage ParseConsentMessage(JsonElement value) => new()
+ {
+ Id = value.GetProperty("id").GetString() ?? string.Empty,
+ Text = value.GetProperty("text").GetString() ?? string.Empty,
+ };
+
+ private static TelemetryConsentStatus ParseConsentStatus(string json)
+ {
+ using var doc = JsonDocument.Parse(json);
+ return ParseConsentStatus(doc.RootElement);
+ }
+
+ private static TelemetryConsentStatus ParseConsentStatus(JsonElement root) => new()
+ {
+ StoredState = ParseConsentState(root.GetProperty("storedState").GetString() ?? string.Empty),
+ EffectiveState = ParseConsentState(root.GetProperty("effectiveState").GetString() ?? string.Empty),
+ Reason = root.TryGetProperty("reason", out var reason) && reason.ValueKind != JsonValueKind.Null
+ ? ParseConsentStatusReason(reason.GetString() ?? string.Empty)
+ : null,
+ Policy = ParsePolicyState(root.GetProperty("policy").GetString() ?? string.Empty),
+ };
+
+ private static TelemetryConsentOutcome ParseConsentOutcome(string json)
+ {
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+ var status = ParseConsentStatus(root);
+ return new TelemetryConsentOutcome
+ {
+ Result = ParseConsentResult(root.GetProperty("result").GetString() ?? string.Empty),
+ StoredState = status.StoredState,
+ EffectiveState = status.EffectiveState,
+ Reason = status.Reason,
+ Policy = status.Policy,
+ };
+ }
+
+ private static TelemetryConsentState ParseConsentState(string value) => value switch
+ {
+ "granted" => TelemetryConsentState.Granted,
+ "denied" => TelemetryConsentState.Denied,
+ "undetermined" => TelemetryConsentState.Undetermined,
+ "not-applicable" => TelemetryConsentState.NotApplicable,
+ _ => throw new JsonException($"unknown telemetry consent state '{value}'"),
+ };
+
+ private static TelemetryPolicyState ParsePolicyState(string value) => value switch
+ {
+ "unrestricted" => TelemetryPolicyState.Unrestricted,
+ "allowed" => TelemetryPolicyState.Allowed,
+ "blocked" => TelemetryPolicyState.Blocked,
+ "not-applicable" => TelemetryPolicyState.NotApplicable,
+ _ => throw new JsonException($"unknown telemetry policy state '{value}'"),
+ };
+
+ private static TelemetryConsentStatusReason ParseConsentStatusReason(string value) => value 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,
+ _ => throw new JsonException($"unknown telemetry consent status reason '{value}'"),
+ };
+
+ private static TelemetryConsentResult ParseConsentResult(string value) => value switch
+ {
+ "granted" => TelemetryConsentResult.Granted,
+ "denied" => TelemetryConsentResult.Denied,
+ "dismissed" => TelemetryConsentResult.Dismissed,
+ "withdrawn" => TelemetryConsentResult.Withdrawn,
+ "alreadyGranted" => TelemetryConsentResult.AlreadyGranted,
+ "policyBlocked" => TelemetryConsentResult.PolicyBlocked,
+ "notApplicable" => TelemetryConsentResult.NotApplicable,
+ _ => throw new JsonException($"unknown telemetry consent result '{value}'"),
+ };
+}
diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs
index e99abb605..691d9899d 100644
--- a/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs
+++ b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs
@@ -56,6 +56,21 @@ public sealed class SandboxPolicy
/// Execution timeout in milliseconds (null = no timeout).
[JsonPropertyName("timeoutMs")]
public uint? TimeoutMs { get; set; }
+
+ ///
+ /// Canonical per-invocation telemetry settings serialized as
+ /// {"telemetry":{"enabled":...}}.
+ ///
+ [JsonPropertyName("telemetry")]
+ public TelemetrySettings? Telemetry { get; set; }
+}
+
+/// Telemetry section of a .
+public sealed class TelemetrySettings
+{
+ /// Whether this invocation opts telemetry on, subject to consent and policy.
+ [JsonPropertyName("enabled")]
+ public bool Enabled { get; set; }
}
///
diff --git a/sdk/dotnet/README.md b/sdk/dotnet/README.md
index 7849735f6..ecb6ef3bb 100644
--- a/sdk/dotnet/README.md
+++ b/sdk/dotnet/README.md
@@ -484,11 +484,15 @@ MXC telemetry is Windows-only and remains off until both of these are true:
1. the user has explicitly granted MXC-owned consent, and
2. the caller opts this invocation in via telemetry settings.
-Telemetry remains off by default unless the caller opts in with `SandboxPolicy.TelemetryEnabled = true` (or the equivalent phase-level `TelemetryEnabled` setting for state-aware requests) and applicable Windows consent/policy gates permit collection.
-
-Any .NET consent surface should stay UI-agnostic, present the canonical
-resource verbatim through a host callback, persist only explicit yes/no
-decisions, treat dismissal and failures as non-grants, and follow the rules in
+Telemetry remains off by default unless the caller opts in with
+`SandboxPolicy.Telemetry = new TelemetrySettings { Enabled = true }` and
+applicable Windows consent/policy gates permit collection.
+
+The .NET consent APIs are UI-agnostic: `MxcTelemetry.RequestConsent` supplies
+the canonical resource to a host callback, while `GetConsentStatus`,
+`NeedsConsentPrompt`, and `WithdrawConsent` provide maintenance operations.
+They persist only explicit yes/no decisions and treat dismissal and failures
+as non-grants. See
[`docs/telemetry/telemetry-consent-design.md`](../../docs/telemetry/telemetry-consent-design.md)
and its
[SDK presenter requirements](../../docs/telemetry/telemetry-consent-design.md#sdk-presenter-requirements).
@@ -498,9 +502,13 @@ and its
An IT administrator can still block MXC telemetry device-wide via MXC's own
registry policy setting. See
[`docs/telemetry/telemetry-administrative-policy.md`](../../docs/telemetry/telemetry-administrative-policy.md)
-for the stable registry contract and interaction rules. Policy and consent
-queries are not yet exposed by the .NET SDK; any eventual query must fail
-closed rather than upgrading an unreadable device state into collection.
+for the stable registry contract and interaction rules. Read-only queries fail
+closed rather than upgrading an unreadable device state into collection. When
+that fallback hides a native or parsing failure, the SDK reports a bounded set
+of distinct failure signatures through `System.Diagnostics.Trace`. Each
+signature is reported once; if a listener rejects it, a future occurrence may
+try again. Reports include the exception type and HRESULT or native error code,
+but exclude exception messages and stack traces.
## Projects
diff --git a/src/Cargo.lock b/src/Cargo.lock
index 104f07376..36e5542da 100644
--- a/src/Cargo.lock
+++ b/src/Cargo.lock
@@ -1567,6 +1567,8 @@ dependencies = [
"mxc-sdk",
"serde",
"serde_json",
+ "tempfile",
+ "wxc_common",
]
[[package]]
diff --git a/src/Cargo.toml b/src/Cargo.toml
index 4a5ed2640..e884b5080 100644
--- a/src/Cargo.toml
+++ b/src/Cargo.toml
@@ -95,7 +95,7 @@ sandbox_spec = { path = "core/generated/base_container_specification" }
seatbelt_common = { path = "backends/seatbelt/common" }
same-file = "1"
serde = { version = "1", features = ["derive"] }
-serde_json = "1"
+serde_json = { version = "1", features = ["raw_value"] }
serde_path_to_error = "0.1"
schemars = "0.8"
sha2 = "0.10"
diff --git a/src/core/mxc-sdk/Cargo.toml b/src/core/mxc-sdk/Cargo.toml
index b3a4bd747..8411cdab0 100644
--- a/src/core/mxc-sdk/Cargo.toml
+++ b/src/core/mxc-sdk/Cargo.toml
@@ -16,6 +16,11 @@ default = []
wslc = ["mxc_engine/wslc"]
# IsolationSession backend (Windows only, experimental).
isolation_session = ["mxc_engine/isolation_session"]
+# Exposes the debug-only telemetry test seams to downstream binding tests.
+test-support = ["wxc_common/test-support"]
+# Enables the hidden policy-JSON bridge consumed by co-versioned language
+# bindings.
+ffi-internals = ["mxc_engine/ffi-internals"]
[dependencies]
wxc_common.workspace = true
diff --git a/src/core/mxc-sdk/README.md b/src/core/mxc-sdk/README.md
index 0d8755d6a..c641801d8 100644
--- a/src/core/mxc-sdk/README.md
+++ b/src/core/mxc-sdk/README.md
@@ -13,9 +13,10 @@ sandboxed process over ordinary pipes, with no pty. The state-aware
## Usage
```rust,no_run
+use std::error::Error;
use mxc_sdk::{build_request, run, SandboxPolicy, WaitOutcome};
-// Describe what to restrict, turn it into a request, fill in the command.
+fn main() -> Result<(), Box> {
let policy = SandboxPolicy {
version: "0.7.0-alpha".to_string(),
filesystem: None,
@@ -24,13 +25,13 @@ let policy = SandboxPolicy {
timeout_ms: Some(10_000),
};
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)?;
assert_eq!(output.outcome, WaitOutcome::Exited(0));
assert_eq!(String::from_utf8_lossy(&output.stdout), "hello\n");
-# Ok::<(), Box>(())
+Ok(())
+}
```
[`run`] is the run-to-completion convenience (spawn + `wait_with_output`); use
@@ -43,6 +44,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`].
@@ -225,9 +230,11 @@ while it runs — persistent bidirectional stdio plus termination. No pty is
allocated; the streams are ordinary pipes.
```rust,no_run
+use std::error::Error;
use std::io::{Read, Write};
use mxc_sdk::{build_request, spawn_sandbox, SandboxPolicy, WaitOutcome};
+fn main() -> Result<(), Box> {
let policy = SandboxPolicy {
version: "0.7.0-alpha".to_string(),
filesystem: None,
@@ -249,7 +256,8 @@ stdout.read_to_string(&mut out)?; // "hello\n"
let outcome = proc.wait()?; // any untaken stream is drained and discarded
assert_eq!(outcome, WaitOutcome::Exited(0));
-# Ok::<(), Box>(())
+Ok(())
+}
```
The handle is modelled on [`std::process::Child`]:
@@ -334,8 +342,10 @@ The example needs this crate's `isolation_session` feature and a host running th
OS-side service.
```rust,no_run
+use std::error::Error;
use mxc_sdk::{run_state_aware_json, exec_attached};
+fn main() -> Result<(), Box> {
// Provision. IsolationSession accepts only the canonical unrestricted-network
// acknowledgment; an absent policy defaults to `block`, which it refuses.
let provisioned = run_state_aware_json(
@@ -358,7 +368,10 @@ let outcome = exec_attached(
r#"{"phase":"exec","sandboxId":"...","process":{"commandLine":"powershell.exe"}}"#,
true, // experimental
)?;
-# Ok::<(), Box>(())
+let _ = outcome;
+let _ = provisioned;
+Ok(())
+}
```
Three backends implement the state-aware lifecycle — IsolationSession, WSLc and
@@ -413,19 +426,23 @@ go through the same parser the executor uses — so a rejected value (e.g. a por
mapping with a zero or duplicated host port) fails at build time, not at spawn.
```rust,no_run
+use std::error::Error;
use mxc_sdk::{
build_request_with_containment, run, Containment, SandboxPolicy, WslcSection,
};
-# let policy = SandboxPolicy {
-# version: "0.7.0-alpha".to_string(),
-# filesystem: None, network: None, ui: None, timeout_ms: None,
-# };
+fn main() -> Result<(), Box> {
+let policy = SandboxPolicy {
+ version: "0.7.0-alpha".to_string(),
+ filesystem: None, network: None, ui: None, timeout_ms: None,
+};
let wslc = WslcSection { image: "python:3.12".to_string(), ..Default::default() };
let mut request = build_request_with_containment(&policy, &Containment::Wslc(wslc), None)?;
request.set_script("python3 -c 'print(42)'").set_experimental(true);
let output = run(request)?;
-# Ok::<(), mxc_sdk::Error>(())
+let _ = output;
+Ok(())
+}
```
Two WSLC-specific limits follow from the SDK's surface: the container has no
@@ -433,6 +450,80 @@ 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 std::error::Error;
+use mxc_sdk::telemetry;
+
+fn main() -> Result<(), Box> {
+let outcome = telemetry::request_consent(Some("en-US"), |prompt| {
+ assert_eq!(prompt.locale, "en-US");
+ Ok(telemetry::ConsentDecision::Yes)
+})?;
+
+let status = telemetry::get_consent_status();
+let withdrawal = telemetry::withdraw_consent()?;
+let _ = (outcome, status, withdrawal);
+Ok(())
+}
+```
+
+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.
+
+### Administrative policy
+
+An IT administrator can block MXC telemetry device-wide via MXC's own
+registry policy 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-administrative-policy.md`](../../../docs/telemetry/telemetry-administrative-policy.md).
+
## Pty allocation
Every entry point except `exec_attached` wires the child's stdio to ordinary
diff --git a/src/core/mxc-sdk/src/lib.rs b/src/core/mxc-sdk/src/lib.rs
index 16af6f428..82125e1b1 100644
--- a/src/core/mxc-sdk/src/lib.rs
+++ b/src/core/mxc-sdk/src/lib.rs
@@ -144,6 +144,14 @@
mod sandbox;
+pub mod telemetry;
+
+#[cfg(feature = "ffi-internals")]
+#[doc(hidden)]
+pub mod ffi_internals {
+ pub use mxc_engine::binding::{parse_policy_json, ParsedPolicy};
+}
+
pub use mxc_engine::configs;
pub use mxc_engine::policy;
pub use mxc_engine::{
diff --git a/src/core/mxc-sdk/src/telemetry.rs b/src/core/mxc-sdk/src/telemetry.rs
new file mode 100644
index 000000000..3d43cd6cb
--- /dev/null
+++ b/src/core/mxc-sdk/src/telemetry.rs
@@ -0,0 +1,525 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Telemetry consent administration.
+//!
+//! A host supplies presentation for the canonical consent resource; the SDK
+//! persists the typed decision. See
+//! `docs/telemetry/telemetry-consent-design.md`.
+//!
+//! ```no_run
+//! use std::error::Error;
+//! use mxc_sdk::telemetry::{self, ConsentDecision, ConsentState};
+//!
+//! fn main() -> Result<(), Box> {
+//! let outcome = telemetry::request_consent(Some("en-US"), |prompt| {
+//! assert_eq!(prompt.locale, "en-US");
+//! Ok(ConsentDecision::Yes)
+//! })?;
+//!
+//! match telemetry::get_consent() {
+//! ConsentState::Granted => println!("telemetry on"),
+//! ConsentState::Denied | ConsentState::Undetermined => println!("telemetry off"),
+//! ConsentState::NotApplicable => {}
+//! }
+//! let _ = outcome;
+//! Ok(())
+//! }
+//! ```
+
+use std::fmt;
+
+use wxc_common::telemetry::consent as inner_consent;
+use wxc_common::telemetry::policy as inner_policy;
+
+/// A stored or effective telemetry consent state.
+#[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 permits collection.
+ pub fn allows_collection(&self) -> bool {
+ Self::to_inner(*self).allows_collection()
+ }
+
+ /// Whether this state needs a consent prompt, without policy evaluation.
+ 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.
+ ///
+ /// The FFI JSON `result` field and language bindings use these exact
+ /// camelCase spellings.
+ pub fn as_str(&self) -> &'static str {
+ match self {
+ Self::Granted => "granted",
+ Self::Denied => "denied",
+ Self::Dismissed => "dismissed",
+ Self::Withdrawn => "withdrawn",
+ Self::AlreadyGranted => "alreadyGranted",
+ Self::PolicyBlocked => "policyBlocked",
+ Self::NotApplicable => "notApplicable",
+ }
+ }
+}
+
+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 telemetry policy for this machine.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum PolicyState {
+ /// No policy is configured.
+ Unrestricted,
+ /// A policy is configured and permits collection.
+ Allowed,
+ /// A policy blocks collection or could not be evaluated.
+ Blocked,
+ /// Not a Windows host.
+ 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.
+ 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,
+ }
+ }
+}
+
+/// Return the consent state currently effective for telemetry authorization.
+///
+/// Use [`get_consent_status`] when the persisted decision is required.
+pub fn get_consent() -> ConsentState {
+ inner_consent::get_consent().into()
+}
+
+/// Read stored and effective consent.
+pub fn get_consent_status() -> ConsentStatus {
+ inner_consent::get_status().into()
+}
+
+/// Invoke a host presenter and persist its 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`].
+///
+/// The synchronous consent-store work runs on short-lived dedicated worker
+/// threads. The presenter itself is awaited on the caller's executor.
+pub async fn request_consent_async(
+ locale: Option<&str>,
+ presenter: F,
+) -> Result
+where
+ F: FnOnce(ConsentPrompt) -> Fut,
+ Fut: std::future::Future