diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Sample/Program.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Sample/Program.cs index fa02237f8..bd97359a0 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Sample/Program.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Sample/Program.cs @@ -27,6 +27,21 @@ TimeoutMs = 30_000, }; +// Set MXC_SAMPLE_CAPTURE_DENIALS=1 to opt into Windows denial capture without +// changing the sample's default host requirements or generating traces by default. +if (OperatingSystem.IsWindows() + && string.Equals( + Environment.GetEnvironmentVariable("MXC_SAMPLE_CAPTURE_DENIALS"), + "1", + StringComparison.Ordinal)) +{ + policy.CaptureDenials = new CaptureDenialsPolicy + { + Mode = CaptureDenialsMode.Block, + RetainEtl = false, + }; +} + Console.WriteLine($"Running: {command}"); try @@ -39,6 +54,23 @@ { Console.WriteLine($"stderr : {result.Stderr.TrimEnd()}"); } + foreach (var warning in result.Warnings) + { + Console.WriteLine($"warning : {warning}"); + } + if (result.OutputMetadata?.CaptureDenials is { } capture) + { + Console.WriteLine($"denials : {capture.OutputPath}"); + if (capture.EtlPath is not null) + { + Console.WriteLine($"ETL : {capture.EtlPath}"); + } + } + if (result.OutputMetadata?.CaptureDenialsError is { } captureError) + { + Console.WriteLine($"capture error: {captureError.Message}"); + Console.WriteLine($"retained ETL : {captureError.EtlPath}"); + } // Streaming variant: spawn the same command as a live process and stream // its stdout as it is produced, then wait for exit. @@ -59,6 +91,19 @@ var streamResult = proc.Wait(); Console.WriteLine($"streamed exit code : {streamResult.ExitCode}"); + if (proc.OutputMetadata?.CaptureDenials is { } streamCapture) + { + Console.WriteLine($"stream denials : {streamCapture.OutputPath}"); + if (streamCapture.EtlPath is not null) + { + Console.WriteLine($"stream ETL : {streamCapture.EtlPath}"); + } + } + if (proc.OutputMetadata?.CaptureDenialsError is { } streamCaptureError) + { + Console.WriteLine($"stream capture error: {streamCaptureError.Message}"); + Console.WriteLine($"stream retained ETL : {streamCaptureError.EtlPath}"); + } } // State-aware lifecycle: provision -> start -> exec -> stop -> deprovision. diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs index 6b76268c6..8b8dca47c 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs @@ -50,13 +50,15 @@ public void SandboxPolicy_SerializesToCamelCaseJson() TimeoutMs = 5000, Filesystem = new FilesystemPolicy { ReadwritePaths = { "/tmp" } }, Ui = new UiPolicy { AllowWindows = true, Clipboard = ClipboardPolicy.Read }, + CaptureDenials = new CaptureDenialsPolicy + { + Mode = CaptureDenialsMode.Allow, + OutputPath = @"C:\logs\denials.json", + RetainEtl = true, + }, }; - var options = new JsonSerializerOptions - { - Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, - }; - var json = JsonSerializer.Serialize(policy, options); + var json = MxcSandbox.SerializePolicy(policy); using var doc = JsonDocument.Parse(json); var root = doc.RootElement; @@ -65,6 +67,35 @@ public void SandboxPolicy_SerializesToCamelCaseJson() Assert.Equal("/tmp", root.GetProperty("filesystem").GetProperty("readwritePaths")[0].GetString()); Assert.Equal("read", root.GetProperty("ui").GetProperty("clipboard").GetString()); Assert.True(root.GetProperty("ui").GetProperty("allowWindows").GetBoolean()); + var capture = root.GetProperty("captureDenials"); + Assert.Equal("allow", capture.GetProperty("mode").GetString()); + Assert.Equal(@"C:\logs\denials.json", capture.GetProperty("outputPath").GetString()); + Assert.True(capture.GetProperty("retainEtl").GetBoolean()); + } + + [Fact] + public void CaptureDenialsPolicy_DefaultsToBlockAndOmitsOutputPath() + { + var policy = new SandboxPolicy + { + Version = "0.8.0-alpha", + CaptureDenials = new CaptureDenialsPolicy(), + }; + using var doc = JsonDocument.Parse(MxcSandbox.SerializePolicy(policy)); + var capture = doc.RootElement.GetProperty("captureDenials"); + + Assert.Equal("block", capture.GetProperty("mode").GetString()); + Assert.False(capture.GetProperty("retainEtl").GetBoolean()); + Assert.False(capture.TryGetProperty("outputPath", out _)); + } + + [Fact] + public void SandboxPolicy_OmitsCaptureDenialsWhenNotConfigured() + { + var policy = new SandboxPolicy { Version = "0.8.0-alpha" }; + using var doc = JsonDocument.Parse(MxcSandbox.SerializePolicy(policy)); + + Assert.False(doc.RootElement.TryGetProperty("captureDenials", out _)); } [Fact] diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcSandbox.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcSandbox.cs index 6c5b50f07..abac34d5c 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcSandbox.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcSandbox.cs @@ -57,7 +57,7 @@ public static RunResult Run(SandboxPolicy policy, string command) ArgumentNullException.ThrowIfNull(policy); ArgumentNullException.ThrowIfNull(command); - var policyJson = JsonSerializer.Serialize(policy, PolicyJsonOptions); + var policyJson = SerializePolicy(policy); var policyBuf = ToNullTerminatedUtf8(policyJson); var commandBuf = ToNullTerminatedUtf8(command); @@ -122,7 +122,7 @@ public static MxcSandboxProcess Spawn(SandboxPolicy policy, string command) ArgumentNullException.ThrowIfNull(policy); ArgumentNullException.ThrowIfNull(command); - var policyJson = JsonSerializer.Serialize(policy, PolicyJsonOptions); + var policyJson = SerializePolicy(policy); var policyBuf = ToNullTerminatedUtf8(policyJson); var commandBuf = ToNullTerminatedUtf8(command); @@ -157,6 +157,12 @@ private static byte[] ToNullTerminatedUtf8(string value) return buffer; } + internal static string SerializePolicy(SandboxPolicy policy) + { + ArgumentNullException.ThrowIfNull(policy); + return JsonSerializer.Serialize(policy, PolicyJsonOptions); + } + private static unsafe string? PtrToString(byte* p) => p is null ? null : Marshal.PtrToStringUTF8((IntPtr)p); diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxOutputMetadata.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxOutputMetadata.cs index 398bd5e26..2e79bb181 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxOutputMetadata.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxOutputMetadata.cs @@ -24,7 +24,10 @@ public sealed class CaptureDenialsErrorOutput [JsonPropertyName("message")] public string Message { get; init; } = string.Empty; - /// Absolute path to the retained ETL trace. + /// + /// Absolute path to the retained ETL trace. Delete this file after use; do not delete its + /// parent directory unless the caller independently owns or positively recognizes it. + /// [JsonPropertyName("etlPath")] public string EtlPath { get; init; } = string.Empty; } @@ -52,7 +55,11 @@ public sealed class CaptureDenialsOutput [JsonPropertyName("deniedResourcesTruncated")] public bool DeniedResourcesTruncated { get; init; } - /// Absolute path to the retained ETL trace, when requested. + /// + /// Absolute path to the retained ETL trace, when requested. Delete this file after use; do + /// not delete its parent directory unless the caller independently owns or positively + /// recognizes it. + /// [JsonPropertyName("etlPath")] public string? EtlPath { get; init; } } diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs index b8fb1e819..d5a2ae12e 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs @@ -28,11 +28,67 @@ public sealed class SandboxPolicy [JsonPropertyName("ui")] public UiPolicy? Ui { get; set; } + /// + /// Windows ProcessContainer denial capture. Its presence enables capture; + /// Linux and macOS backends ignore it. + /// + [JsonPropertyName("captureDenials")] + public CaptureDenialsPolicy? CaptureDenials { get; set; } + /// Execution timeout in milliseconds (null = no timeout). [JsonPropertyName("timeoutMs")] public uint? TimeoutMs { get; set; } } +/// +/// How captureDenials handles each ungranted access check while recording it. +/// +public enum CaptureDenialsMode +{ + /// + /// Keep the access denied and record the denial, preserving deny-by-default containment. + /// + Block, + + /// + /// Allow and record the access. This relaxes containment for the run and emits a warning. + /// and + /// expose that + /// warning; streaming processes returned by + /// currently do not. + /// + Allow, +} + +/// +/// Windows ProcessContainer denial-capture settings. The presence of this section enables +/// capture and reports the resulting document through . +/// +public sealed class CaptureDenialsPolicy +{ + /// How ungranted access checks are handled while recording them. + [JsonPropertyName("mode")] + public CaptureDenialsMode Mode { get; set; } = CaptureDenialsMode.Block; + + /// + /// Optional absolute path for the JSON denials document. The parent directory must exist. + /// MXC inserts a per-run identifier into the file stem and reports the actual path through + /// output metadata. When omitted, MXC uses a run-unique file in the system temporary + /// directory. + /// + [JsonPropertyName("outputPath")] + public string? OutputPath { get; set; } + + /// + /// Preserve the sealed ETL trace and report its path through output metadata. Retained traces + /// can contain sensitive paths and identifiers; callers must delete the reported trace after + /// use. Do not delete its parent directory unless the caller independently owns or positively + /// recognizes that directory. + /// + [JsonPropertyName("retainEtl")] + public bool RetainEtl { get; set; } +} + /// Filesystem section of a . public sealed class FilesystemPolicy { diff --git a/sdk/dotnet/README.md b/sdk/dotnet/README.md index 790c251e7..cf4d700e4 100644 --- a/sdk/dotnet/README.md +++ b/sdk/dotnet/README.md @@ -44,14 +44,61 @@ generated JSON document and carries its summary. When ETL retention is enabled, `OutputMetadata.CaptureDenials.EtlPath` identifies the retained trace. If capture finalization fails after retaining the trace, `OutputMetadata.CaptureDenialsError` carries both the failure and its path. -After deleting a retained ETL, also remove its now-empty per-run parent -directory. +Delete every reported ETL file after use. Do not delete its parent directory +unless your application independently owns or positively recognizes that +directory; it may be a shared location such as the system temporary directory +or the configured output directory. `RunResult.Warnings` carries security warnings raised during the run — notably when `permissiveLearningMode` disabled deny-by-default. MXC never writes these to the host's stderr, so inspecting `Warnings` is the only way to learn that containment was relaxed. +### Denial capture (Windows) + +Set `SandboxPolicy.CaptureDenials` to record Windows ProcessContainer accesses +that the policy does not grant: + +```csharp +var policy = new SandboxPolicy +{ + Version = "0.8.0-alpha", + CaptureDenials = new CaptureDenialsPolicy + { + // Block is the safe default: deny the access and record it. + // Allow records what would have been denied but relaxes containment. + Mode = CaptureDenialsMode.Block, + // Optional absolute destination. MXC inserts a per-run ID into the stem. + OutputPath = @"C:\logs\denials.json", + // Retained ETLs can contain sensitive paths and identifiers. + RetainEtl = false, + }, +}; + +RunResult result = MxcSandbox.Run(policy, "cmd /c type C:\\blocked.txt"); +if (result.OutputMetadata?.CaptureDenials is { } capture) +{ + Console.WriteLine($"denials: {capture.OutputPath}"); + Console.WriteLine($"unique denials: {capture.TotalDenials}"); +} + +foreach (string warning in result.Warnings) +{ + Console.Error.WriteLine(warning); +} +``` + +`OutputPath`'s parent directory must already exist. When it is omitted, MXC +uses a run-unique file in the system temporary directory. Delete every reported +ETL file after use, including an ETL reported through +`CaptureDenialsError.EtlPath`; do not delete its parent directory unless your +application independently owns or positively recognizes that directory. +`CaptureDenialsMode.Allow` intentionally weakens deny-by-default containment; +always inspect `RunResult.Warnings`. Streaming processes currently do not +expose warnings, so use `Run` or `RunAsync` when warning inspection is required. +Linux and macOS accept the policy for cross-platform parity but do not act on +it. + ### Streaming `MxcSandbox.Spawn(policy, command)` returns a live `MxcSandboxProcess` you can