From 724daca1b84e58f685117ff7d375db5721aa6aa9 Mon Sep 17 00:00:00 2001 From: Ross Nichols Date: Mon, 9 Nov 2026 16:05:59 -0800 Subject: [PATCH 1/5] Add caller-owned policy preparation Return structured policy refusals and prepare restricted candidates without creation. Preserve V1 creation APIs; callers own acceptance, storage and retries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/api-reference/dotnet/v1/README.md | 7 +- docs/api-reference/dotnet/v1/api.md | 2 +- docs/api-reference/dotnet/v1/dev.md | 266 +++++- docs/api-reference/dotnet/v1/types.md | 8 + docs/api-reference/node/v1/README.md | 2 +- docs/api-reference/node/v1/api.md | 2 +- docs/api-reference/node/v1/dev.md | 239 +++++- docs/api-reference/node/v1/types.md | 8 +- docs/api-reference/rust/v1/README.md | 2 +- docs/api-reference/rust/v1/api.md | 2 +- docs/api-reference/rust/v1/dev.md | 235 ++++- docs/api-reference/rust/v1/types.md | 6 +- docs/backends/nvx/nvx.md | 15 +- .../1634-creation-policy-correction/README.md | 2 - .../1634-creation-policy-correction/api.md | 11 +- .../ci-validation-infrastructure.md | 32 +- .../process-container-adding-os-features.md | 106 ++- ...ProcessSecurityEnvironmentPolicyResult.fbs | 63 ++ ...ProcessSecurityEnvironment.provenance.toml | 7 + scripts/ci/build-windows-sdk-test-bundle.ps1 | 23 +- scripts/ci/lib/TestSuiteResults.ps1 | 18 +- scripts/ci/run_backend_validation_tests.ps1 | 27 +- .../test-policy-preparation-test-results.ps1 | 76 ++ scripts/versioning/check-psec-codegen.js | 167 ++-- scripts/versioning/regenerate-psec.ps1 | 150 ++-- .../Microsoft.Mxc.Sdk.AotSmokeTest/Program.cs | 150 +++- .../DevEntryPointTests.cs | 594 +++++++++++++ .../MxcExceptionTests.cs | 17 + .../MxcProcessTests.cs | 166 +++- .../NativeErrorTests.cs | 164 +++- .../V1/MxcContainerTests.cs | 6 + .../V1/V1ApiSurfaceTests.cs | 47 + .../Microsoft.Mxc.Sdk/MxcJsonSerialization.cs | 31 + .../Microsoft.Mxc.Sdk/Native/NativeError.cs | 37 +- .../PolicyFailureValueJsonConverter.cs | 30 + .../Microsoft.Mxc.Sdk/V1/Dev/MxcContainer.cs | 151 +++- .../V1/Dev/PolicyFailures.cs | 344 ++++++++ .../V1/Dev/PolicyPreparationOptions.cs | 11 + .../V1/Dev/PreparedPolicyRequest.cs | 224 +++++ .../Microsoft.Mxc.Sdk/V1/MxcException.cs | 9 +- sdk/dotnet/README.md | 91 +- sdk/node/README.md | 52 +- sdk/node/src/bindings/native-error.ts | 24 +- sdk/node/src/bindings/policy-preparation.ts | 108 +++ sdk/node/src/bindings/probe.ts | 3 +- sdk/node/src/bindings/pty.ts | 3 +- sdk/node/src/bindings/run.ts | 18 +- sdk/node/src/bindings/state-aware.ts | 8 +- sdk/node/src/bindings/streaming.ts | 9 +- sdk/node/src/v1/dev/index.ts | 56 ++ sdk/node/src/v1/dev/json-duplicate-keys.ts | 37 + sdk/node/src/v1/dev/policy-failures.ts | 292 +++++++ sdk/node/src/v1/dev/policy-preparation.ts | 155 ++++ sdk/node/src/v1/errors.ts | 32 +- sdk/node/tests/integration/dev-api.ts | 45 +- sdk/node/tests/integration/dev.test.ts | 196 ++++- sdk/node/tests/integration/test-helpers.ts | 27 +- .../windows-process-container.test.ts | 19 +- sdk/node/tests/unit/binding-run.test.ts | 346 +++++++- sdk/node/tests/unit/inprocess-run.test.ts | 7 + sdk/node/tests/unit/streaming-binding.test.ts | 48 +- sdk/node/tests/unit/v1-dev.test.ts | 543 ++++++++++++ src/ffi/mxc_ffi/src/error_detail.rs | 36 + src/ffi/mxc_ffi/src/lib.rs | 93 +- .../mxc_ffi/src/policy_preparation_tests.rs | 153 ++++ src/ffi/mxc_ffi/tests/ffi.rs | 2 + src/mxc-sdk/Cargo.toml | 4 + src/mxc-sdk/README.md | 14 + src/mxc-sdk/examples/policy_feedback.rs | 212 +++++ .../common/base_container_helpers.rs | 19 +- .../common/base_container_runner.rs | 254 ++++-- .../common/launch_diagnostics.rs | 77 ++ .../backends/process_container/common/mod.rs | 6 + .../common/native_capture.rs | 6 + .../common/network_policy_helpers.rs | 31 + .../policy_preparation_backend_tests.rs | 427 +++++++++ .../common/policy_projection.rs | 371 ++++++++ .../common/policy_restriction.rs | 517 +++++++++++ .../common/policy_restriction_tests.rs | 749 ++++++++++++++++ .../process_container/common/secenv.rs | 289 ++++++- .../process_container/common/secenv_policy.rs | 213 +++++ .../common/secenv_policy_tests.rs | 812 ++++++++++++++++++ .../common/working_directory.rs | 26 +- src/mxc-sdk/src/core/mod.rs | 2 + src/mxc-sdk/src/core/mxc_common/mod.rs | 2 + src/mxc-sdk/src/core/mxc_common/models.rs | 156 +++- src/mxc-sdk/src/core/mxc_common/mxc_error.rs | 72 +- .../src/core/mxc_common/policy_failures.rs | 461 ++++++++++ .../src/core/mxc_common/policy_preparation.rs | 330 +++++++ .../src/core/mxc_common/script_runner.rs | 3 + src/mxc-sdk/src/core/mxc_common/splice.rs | 40 + src/mxc-sdk/src/core/mxc_engine/dispatch.rs | 17 +- src/mxc-sdk/src/core/mxc_engine/error.rs | 34 +- src/mxc-sdk/src/core/mxc_engine/mod.rs | 2 + .../src/core/mxc_engine/policy_preparation.rs | 64 ++ .../mxc_engine/policy_preparation_tests.rs | 41 + .../failure_class_generated.rs | 106 +++ .../failure_reason_generated.rs | 110 +++ .../policy_access_generated.rs | 98 +++ .../policy_detail_generated.rs | 393 +++++++++ .../policy_outcome_generated.rs | 99 +++ .../policy_result_flags_generated.rs | 106 +++ .../policy_result_generated.rs | 352 ++++++++ .../policy_result_limit_generated.rs | 102 +++ .../policy_result_version_generated.rs | 90 ++ .../required_action_generated.rs | 106 +++ .../resource_kind_generated.rs | 98 +++ .../value_kind_generated.rs | 95 ++ .../mod.rs | 30 + .../README.md | 16 +- src/mxc-sdk/src/dev.rs | 122 +++ src/mxc-sdk/src/lib.rs | 5 + src/mxc-sdk/tests/dev_compatibility.rs | 32 + .../tests/streaming_processcontainer.rs | 145 ++++ 114 files changed, 12823 insertions(+), 393 deletions(-) create mode 100644 external/windows-sdk/ExperimentalProcessSecurityEnvironmentPolicyResult.fbs create mode 100644 scripts/ci/test-policy-preparation-test-results.ps1 create mode 100644 sdk/dotnet/Microsoft.Mxc.Sdk/PolicyFailureValueJsonConverter.cs create mode 100644 sdk/dotnet/Microsoft.Mxc.Sdk/V1/Dev/PolicyFailures.cs create mode 100644 sdk/dotnet/Microsoft.Mxc.Sdk/V1/Dev/PolicyPreparationOptions.cs create mode 100644 sdk/dotnet/Microsoft.Mxc.Sdk/V1/Dev/PreparedPolicyRequest.cs create mode 100644 sdk/node/src/bindings/policy-preparation.ts create mode 100644 sdk/node/src/v1/dev/json-duplicate-keys.ts create mode 100644 sdk/node/src/v1/dev/policy-failures.ts create mode 100644 sdk/node/src/v1/dev/policy-preparation.ts create mode 100644 src/ffi/mxc_ffi/src/policy_preparation_tests.rs create mode 100644 src/mxc-sdk/examples/policy_feedback.rs create mode 100644 src/mxc-sdk/src/backends/process_container/common/policy_preparation_backend_tests.rs create mode 100644 src/mxc-sdk/src/backends/process_container/common/policy_projection.rs create mode 100644 src/mxc-sdk/src/backends/process_container/common/policy_restriction.rs create mode 100644 src/mxc-sdk/src/backends/process_container/common/policy_restriction_tests.rs create mode 100644 src/mxc-sdk/src/backends/process_container/common/secenv_policy.rs create mode 100644 src/mxc-sdk/src/backends/process_container/common/secenv_policy_tests.rs create mode 100644 src/mxc-sdk/src/core/mxc_common/policy_failures.rs create mode 100644 src/mxc-sdk/src/core/mxc_common/policy_preparation.rs create mode 100644 src/mxc-sdk/src/core/mxc_engine/policy_preparation.rs create mode 100644 src/mxc-sdk/src/core/mxc_engine/policy_preparation_tests.rs create mode 100644 src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/failure_class_generated.rs create mode 100644 src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/failure_reason_generated.rs create mode 100644 src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/policy_access_generated.rs create mode 100644 src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/policy_detail_generated.rs create mode 100644 src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/policy_outcome_generated.rs create mode 100644 src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/policy_result_flags_generated.rs create mode 100644 src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/policy_result_generated.rs create mode 100644 src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/policy_result_limit_generated.rs create mode 100644 src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/policy_result_version_generated.rs create mode 100644 src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/required_action_generated.rs create mode 100644 src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/resource_kind_generated.rs create mode 100644 src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/value_kind_generated.rs create mode 100644 src/mxc-sdk/src/core/process_security_environment_policy_result/mod.rs create mode 100644 src/mxc-sdk/tests/dev_compatibility.rs diff --git a/docs/api-reference/dotnet/v1/README.md b/docs/api-reference/dotnet/v1/README.md index b93bcb42f6..7a689a62eb 100644 --- a/docs/api-reference/dotnet/v1/README.md +++ b/docs/api-reference/dotnet/v1/README.md @@ -7,8 +7,13 @@ Filesystem discovery helpers use `Microsoft.Mxc.Sdk.V1.Policy.Filesystem`. Discovery, probes, telemetry, and policy helpers use the same versioned boundary. - [Operation signatures](api.md) -- [V1.Dev exact-JSON API](https://github.com/microsoft/mxc/blob/main/docs/api-reference/dotnet/v1/dev.md) +- [V1.Dev MXC request JSON API and policy preparation](https://github.com/microsoft/mxc/blob/main/docs/api-reference/dotnet/v1/dev.md) - [Types and fields](types.md) - [Cross-SDK requirements](../../README.md) Request data and operation controls are separate. Initial PTY size belongs to the corresponding PTY options; runtime resize remains a process-handle operation. Backend policy enforcement and support checks remain native. + +V1.Dev adds separate Windows ProcessContainer policy preparation. +[Preparation types and public JSON helpers](dev.md#policy-preparation-and-caller-owned-observations) +support typed extraction from `MxcException.Details` and lossless +application-owned storage, including Native AOT. diff --git a/docs/api-reference/dotnet/v1/api.md b/docs/api-reference/dotnet/v1/api.md index e51b6e184c..7184a5fd86 100644 --- a/docs/api-reference/dotnet/v1/api.md +++ b/docs/api-reference/dotnet/v1/api.md @@ -24,7 +24,7 @@ IsolationSession-only. Seatbelt PTY rejects `guiAccess`. Terminal handles give the caller explicit input, output, resize, and process ownership; there is no separate public attached-console launch API. Typed V1 does not accept ["MXC request JSON"](https://github.com/microsoft/mxc/blob/main/docs/schema.md#mxc-request-json); the -[V1.Dev exact-JSON API](https://github.com/microsoft/mxc/blob/main/docs/api-reference/dotnet/v1/dev.md) +[V1.Dev MXC request JSON API and policy preparation](https://github.com/microsoft/mxc/blob/main/docs/api-reference/dotnet/v1/dev.md) accepts caller-authored requests separately. Captured existing-container execution closes its output readers on timeout diff --git a/docs/api-reference/dotnet/v1/dev.md b/docs/api-reference/dotnet/v1/dev.md index 370fd58e6a..d06a561bcf 100644 --- a/docs/api-reference/dotnet/v1/dev.md +++ b/docs/api-reference/dotnet/v1/dev.md @@ -1,4 +1,4 @@ -# .NET V1.Dev exact-JSON API +# .NET V1.Dev MXC request JSON API and policy preparation > **Audience:** MXC consumers @@ -11,6 +11,11 @@ Phase-specific methods require the matching JSON `phase`. Use `JsonOptions` for non-PTY calls and `PtyJsonOptions` for PTY calls. Both define `Experimental`, independently of the JSON version; PTY options also define `MxcPtySize? Size` (24 rows by 80 columns by default). +Creation methods execute the supplied request strictly. `PreparePolicyJson` +and its async form take observations separately and use +`PolicyPreparationOptions`, which defines only `Experimental`. Preparation +does not create a container; callers decide whether to accept its proposed +changes. Execution returns V1 result and process types. Lifecycle and validation return complete response JSON strings, including backend metadata. When importing both namespaces, qualify the Dev facade, for example with @@ -18,6 +23,10 @@ importing both namespaces, qualify the Dev facade, for example with ```csharp // Microsoft.Mxc.Sdk.V1.Dev.MxcContainer +PreparedPolicyRequest PreparePolicyJson(string json, PolicyFailures failures, + PolicyPreparationOptions? options = null); +Task PreparePolicyJsonAsync(string json, PolicyFailures failures, + PolicyPreparationOptions? options = null, CancellationToken cancellationToken = default); ExecutionResult RunJson(string json, JsonOptions? options = null); Task RunJsonAsync(string json, JsonOptions? options = null, CancellationToken cancellationToken = default); @@ -56,9 +65,11 @@ string ValidateProcessJson(string json, JsonOptions? options = null); Task ValidateProcessJsonAsync(string json, JsonOptions? options = null); ``` -PTY methods are synchronous. Async run/spawn methods accept cancellation -tokens. Canceling `RunJsonAsync` stops awaiting, not native execution; -canceling `RunInContainerJsonAsync` disposes its owned process. Async +PTY methods are synchronous. Async preparation, run, and spawn methods accept +cancellation tokens. Canceling `RunJsonAsync` stops awaiting, not native +execution; canceling `PreparePolicyJsonAsync` similarly stops awaiting, not +preparation. +Canceling `RunInContainerJsonAsync` disposes its owned process. Async lifecycle management and validation calls run off the caller's thread without cancellation tokens. @@ -66,3 +77,250 @@ Captured output is lossy UTF-8 text. Callers own and dispose live handles; existing-container execution leaves the container caller-owned. A descendant holding an output pipe open can delay captured execution completion after a workload timeout. + +## Policy preparation and caller-owned observations + +`PreparePolicyJson(json, failures, options)` validates untrusted creation-policy +observations against the current MXC creation request and proposes only +supported restrictive changes for Windows ProcessContainer. It creates no +container and returns complete prepared request JSON plus a change report. +Unsupported backends, phases, actions, and unsafe changes fail explicitly. + +Applications own storage, acceptance of the proposed changes, and the decision +to make another strict creation call. Observations are not policy approval and +do not establish that a resource is complete or that a change is safe. The +creation backend reevaluates the current creator's policy on every attempt. +Each creation call makes one native creation attempt and executes only the +supplied request. MXC keeps no observations between calls. + +All types below belong to `Microsoft.Mxc.Sdk.V1.Dev`: + +```csharp +public sealed class JsonOptions +{ + public bool Experimental { get; set; } +} + +public sealed class PolicyPreparationOptions +{ + public bool Experimental { get; set; } +} + +public enum PolicyChangeOperation +{ + Add, + Remove, + Replace, +} + +public sealed class PolicyChange +{ + public PolicyChangeOperation Operation { get; init; } + public string Path { get; init; } + public JsonElement Before { get; init; } + public JsonElement After { get; init; } +} + +public sealed class PreparedPolicyRequest +{ + public string RequestJson { get; init; } + public PolicyChange[] Changes { get; init; } + + public string ToJson(); + public static PreparedPolicyRequest FromJson(string json); +} + +public sealed class PolicyFailures +{ + public uint FormatVersion { get; init; } = 1; + public PolicyFailureResult[] Results { get; init; } = []; + + public string ToJson(); + public static PolicyFailures FromJson(string json); + public static PolicyFailures? FromException(MxcException exception); + public PolicyFailures MergedWith(PolicyFailures other); +} + +public sealed class PolicyFailureResult +{ + public uint Version { get; init; } = 1; + public uint MinimumReaderVersion { get; init; } = 1; + public uint Outcome { get; init; } + public PolicyFailureDetail[] Details { get; init; } = []; +} + +public sealed class PolicyFailureDetail +{ + public uint FailureClass { get; init; } + public uint FailureReason { get; init; } + public uint RequiredAction { get; init; } + public uint ResourceKind { get; init; } + public uint ValueKind { get; init; } + public uint Flags { get; init; } + public ulong RequestedValue { get; init; } + public ulong RequiredValue { get; init; } + public ushort[]? Resource { get; init; } + + public string? GetResourceText(); +} +``` + +### Prepared request and change report + +The result JSON is +`{ "requestJson": "...", "changes": [{ "operation": "replace", "path": "/example", "before": null, "after": true }] }`. +`requestJson` contains complete MXC request JSON. `path` is an RFC 6901 JSON +Pointer; `~0` escapes `~` and `~1` escapes `/`. Changes retain their order. +The enum uses the named lowercase JSON strings `add`, `remove`, and `replace`, +not numeric enum values. + +Both `before` and `after` are required JSON values, represented by non-nullable +`JsonElement` properties. Add uses JSON null for the absent original value; +Remove uses JSON null for the absent prepared value. Replace can have an actual +JSON null on either side. The operation distinguishes absence from replacing +an actual JSON null value. The SDK owns the JSON elements independently of +native memory and any source `JsonDocument`. + +`PreparedPolicyRequest.ToJson` and `FromJson` use source-generated serialization +and validate shapes, required fields, operations, JSON Pointers, and limits. +Malformed data throws `JsonException` without echoing input or attaching parser +exceptions. Storage validation is not backend-policy validation or caller +acceptance. Native preparation failures set the `MxcException` `Code`, +`Operation`, `NativeCode`, and `Message` fields. + +Preparation storage JSON uses compact escaping and is not intended for direct +embedding in HTML; escape it for HTML separately. Storage checks embedded +`RequestJson` syntax, object shape, encoding, and depth without normalizing its +text. The native engine validates the declared request contract, including +arbitrary `_comment` JSON. Duplicate members inside that annotation remain in +the request text; duplicate preparation-envelope properties are rejected. + +| Preparation limit | Maximum | +|---|---:| +| Preparation input MXC request UTF-8 bytes | 8 MiB | +| Prepared result UTF-8 bytes, including the change report | 32 MiB | +| Changes | 4096 | + +Preparation may expand the candidate request beyond the 8 MiB input bound. +The complete stored result, including its request JSON and changes, must fit +within 32 MiB. + +The native operation owns remediation decisions and the stricter observation +input bounds listed below. The async method uses the same safe blocking-call +wrapper as captured execution. Its cancellation token comes last. + +For example, after the application has saved observations from a refused call: + +```csharp +using Microsoft.Mxc.Sdk.V1; +using Dev = Microsoft.Mxc.Sdk.V1.Dev; + +Dev.PolicyFailures failures = Dev.PolicyFailures.FromJson( + await File.ReadAllTextAsync("policy-failures.json")); +Dev.PreparedPolicyRequest prepared = await Dev.MxcContainer.PreparePolicyJsonAsync( + requestJson, failures, cancellationToken: cancellationToken); +await File.WriteAllTextAsync("prepared-request.json", prepared.ToJson(), cancellationToken); + +// The application must review and accept the changes before executing RequestJson. +Console.WriteLine("Review prepared-request.json. Type 'accept' to execute its request."); +if (Console.ReadLine() == "accept") +{ + ExecutionResult result = await Dev.MxcContainer.RunJsonAsync( + prepared.RequestJson, cancellationToken: cancellationToken); +} +``` + +### Observations and storage + +`FormatVersion` identifies the MXC storage format independently of native +`Version` and `MinimumReaderVersion`. Format version 1 is supported. +Native versions, numeric discriminators, outcomes, and flags retain unknown +`uint` values for inspection. Known native outcomes are 0 (unknown), +1 (blocked), and 2 (evaluation failed). `RequestedValue` describes the +request Windows evaluated, which need not be the request supplied on a +later call. + +Use the public constant groups for comparisons, for example +`result.Outcome == PolicyFailureOutcomes.Blocked` or +`detail.FailureReason == PolicyFailureReasons.CapabilityNotAllowed`. +Properties remain `uint` or `ulong`, not closed enums; JSON keeps the numeric +encodings below and unknown values remain inspectable. Names do not authorize +replay. + +`PolicyFailureNativeCodes.PolicyBlocked` is the `const string` `"0x800704EC"` +for `MxcException.NativeCode`: +`HRESULT_FROM_WIN32(ERROR_ACCESS_DISABLED_BY_POLICY)`. The status alone is not +a retry decision; also inspect the native operation, +structured outcome, progress, and application stop conditions. + +| Static class | Public constants | +|---|---| +| `PolicyFailureOutcomes` (`uint`) | `Unknown = 0`, `Blocked = 1`, `EvaluationFailed = 2` | +| `PolicyFailureClasses` (`uint`) | `None = 0`, `Network = 1`, `Filesystem = 2`, `EnforcementMode = 3` | +| `PolicyFailureReasons` (`uint`) | `None = 0`, `CapabilityNotAllowed = 1`, `AccessExceedsCeiling = 2`, `DenyRequired = 3`, `RequestPathLimit = 4` | +| `PolicyFailureActions` (`uint`) | `None = 0`, `RemoveCapability = 1`, `RestrictFilesystemAccess = 2`, `ReducePathCount = 3` | +| `PolicyFailureResourceKinds` (`uint`) | `None = 0`, `Path = 1`, `Capability = 2` | +| `PolicyFailureValueKinds` (`uint`) | `None = 0`, `Access = 1`, `PathCount = 2` | +| `PolicyFailureAccessValues` (`ulong`) | `None = 0`, `ReadOnly = 1`, `ReadWrite = 2`; for requested/required values when `ValueKind` is `PolicyFailureValueKinds.Access` | +| `PolicyFailureFlags` (`uint`) | `None = 0`, `HasActionableDetails = 1`, `ResourceComplete = 2`, `ResourceUnavailable = 4`; combine or inspect as a bitmask | + +`ToJson` and `FromJson` use source-generated serialization without reflection +and are supported by Native AOT. JSON field names are camelCase. +`requestedValue` and `requiredValue` are decimal strings, preserving the +entire `ulong` range. Other numeric fields are JSON numbers. Resource data +is an optional array of UTF-16 code units without a terminator. Omitted +resources, empty arrays, and malformed UTF-16 remain distinguishable. +JSON readers normalize `resource: null` to absence, and serialization omits it. +`GetResourceText()` returns null for an absent resource and throws +`DecoderFallbackException` for malformed UTF-16; it does not replace +invalid units, include resource bytes in decoder diagnostics, or establish +that the text is actionable. + +`PolicyFailures.FromException(error)` extracts `error.Details.policyFailures`. +It returns null when the property is absent. If present observations are +invalid, contain unknown fields, use an unsupported format version, or exceed +storage limits, it throws a new `MxcException` with `Code == BackendError` and +the message `native policy failures are malformed`. `FromJson` reports these +problems in stored observations with `JsonException`. The original error and +its other details remain available. Validation diagnostics do not echo +supplied JSON or attach parser exceptions containing it. Native failures +preserve their original `Code`, `Operation`, `NativeCode`, and generic message. +Policy-specific resources, capabilities, and requested or required values +remain structured and are not converted into exception text, stacks, or +warnings. +Policy refusals retain high-level IT-managed-policy guidance. + +Persist `PolicyFailures.ToJson()`, not the complete `Details` object, and pass +the restored value to preparation. Companion diagnostics and retry budgets +are application control state and need separate persistence if stopping +decisions must survive a restart. Parsing stored observations is not proof +that they can be used to prepare a request. Observation `formatVersion`, +native batch versions, and MXC request JSON `version` identify separate +contracts. + +A creation refusal reports that attempt's observations, not observations passed +to an earlier preparation call. Use `prior.MergedWith(current)` to combine them. +The helper validates both storage inputs and appends distinct complete native +batches in first-observed order, preserving detail order and resource presence. +It returns owned arrays, including resource arrays, without changing either +input. A malformed input or a complete merge exceeding storage limits throws +`JsonException`; neither report is truncated. Keep the original reports +separately on that failure and stop rather than guessing which details to +discard. + +| Observation limit | Native preparation input | JSON storage helpers | +|---|---:|---:| +| UTF-8 bytes | 4 MiB | 4.5 MiB | +| Results | 64 | 65 | +| Total details | 1024 | 1088 | +| Total resource accounting units | 262144 | 294912 | + +Both paths allow at most 64 details and 32768 resource accounting units per +result. Each present resource counts its array length plus one unit. +Storage may preserve accumulated observations that exceed preparation input +bounds; successful storage does not imply that preparation accepts them. +Native validation owns the stricter input limits and all remediation decisions. + +Successful creation returns the usual `ExecutionResult` or `MxcProcess`, with +the same warnings, stdin, cancellation, and ownership semantics. Observations +remain structured on errors. diff --git a/docs/api-reference/dotnet/v1/types.md b/docs/api-reference/dotnet/v1/types.md index 589e84252b..4bea7c9502 100644 --- a/docs/api-reference/dotnet/v1/types.md +++ b/docs/api-reference/dotnet/v1/types.md @@ -587,6 +587,7 @@ public sealed class MxcException : Exception public string? Operation { get; } public string? NativeCode { get; } public string? Remediation { get; } + public System.Text.Json.JsonElement? Details { get; } public MxcException(ErrorCode code, string message) : this(code, message, null, null, null); @@ -595,6 +596,13 @@ public sealed class MxcException : Exception } ``` +`Details` preserves optional backend data independently of native memory and +the original JSON document. The +[V1.Dev observation helpers](dev.md#observations-and-storage) +provide typed extraction and Native AOT-compatible storage of +`Details.policyFailures`. Other backend details remain available through +the `JsonElement`; the exception message does not include them. + ## `Microsoft.Mxc.Sdk.V1.MxcLifecycle` diff --git a/docs/api-reference/node/v1/README.md b/docs/api-reference/node/v1/README.md index 459c839d01..4ba1974096 100644 --- a/docs/api-reference/node/v1/README.md +++ b/docs/api-reference/node/v1/README.md @@ -7,7 +7,7 @@ Filesystem discovery helpers and their result/options types use `policy.filesyst Discovery, probes, telemetry, and policy helpers use the same versioned boundary. - [Operation signatures](api.md) -- [V1.Dev exact-JSON API](https://github.com/microsoft/mxc/blob/main/docs/api-reference/node/v1/dev.md) +- [V1.Dev MXC request JSON API and policy preparation](https://github.com/microsoft/mxc/blob/main/docs/api-reference/node/v1/dev.md) - [Types and fields](types.md) - [Cross-SDK requirements](../../README.md) diff --git a/docs/api-reference/node/v1/api.md b/docs/api-reference/node/v1/api.md index 2a1606c6b9..3431fde739 100644 --- a/docs/api-reference/node/v1/api.md +++ b/docs/api-reference/node/v1/api.md @@ -25,7 +25,7 @@ rejects `guiAccess`. Terminal handles give the caller explicit input, output, resize, and process ownership; there is no separate attached-console launch API. Typed V1 does not accept ["MXC request JSON"](https://github.com/microsoft/mxc/blob/main/docs/schema.md#mxc-request-json); the -[V1.Dev exact-JSON API](https://github.com/microsoft/mxc/blob/main/docs/api-reference/node/v1/dev.md) +[V1.Dev MXC request JSON API and policy preparation](https://github.com/microsoft/mxc/blob/main/docs/api-reference/node/v1/dev.md) accepts caller-authored requests separately. Captured existing-container execution returns partial text on timeout with `timedOut: true`, without waiting for output streams to close. diff --git a/docs/api-reference/node/v1/dev.md b/docs/api-reference/node/v1/dev.md index 4d8ced9236..2d2d41895f 100644 --- a/docs/api-reference/node/v1/dev.md +++ b/docs/api-reference/node/v1/dev.md @@ -1,4 +1,4 @@ -# Node V1.Dev exact-JSON API +# Node V1.Dev MXC request JSON API and policy preparation > **Audience:** MXC consumers @@ -8,13 +8,21 @@ MXC validates the unchanged document against its declared contract and checks policy and backend availability. `JsonOptions` contains optional `experimental?: boolean` authorization, -independent of the authored JSON version. `PtyJsonOptions` additionally +independent of the authored JSON version. `runJson` and `spawnJson` perform +strict creation and accept only this options shape. `PtyJsonOptions` also contains optional `size?: MxcPtySize` (24 rows by 80 columns by default). Execution returns the existing V1 result or process types. Lifecycle and validation return the complete native response JSON string. Phase-specific methods reject a different phase before native dispatch. ```typescript +export interface JsonOptions { + experimental?: boolean; +} +export interface PtyJsonOptions extends JsonOptions { + size?: MxcPtySize; +} + export function runJson(json: string, options?: JsonOptions): Promise; export function spawnJson(json: string, options?: JsonOptions): Promise; export function spawnWithPtyJson(json: string, options?: PtyJsonOptions): Promise; @@ -49,3 +57,230 @@ combined terminal output and the exit code. Structured warnings and output metadata are unavailable on this route. The executor enforces `process.timeout`, but `wait().timedOut` is `false`: the terminal channel reports only an exit code. + +## Separate policy preparation + +A supported Windows ProcessContainer creation refusal can include structured +observations in `MxcError.details.policyFailures`. Extract an owned copy with +`policyFailuresFromError`. Creation attempts remain strict and report their +own observations; the application combines earlier and newer batches with +`mergePolicyFailures` when needed. + +`preparePolicyJson(requestJson, failures, options?)` validates the observations +and asks the native backend to prepare supported restrictive changes. It +creates no container and leaves the original request string and observations +unchanged. +Native filesystem work runs on Koffi's worker pool rather than blocking the +Node event loop. The result contains the candidate MXC request JSON and its +ordered edits. The application inspects and accepts the candidate before +passing `prepared.requestJson` to `runJson` or `spawnJson`. + +Preparation has a separate extensible `PolicyPreparationOptions` interface. +Its `experimental` authorization is independent of the request's declared +contract version and must also be supplied to subsequent creation when +required. Observations are untrusted data, not policy approval. Unknown native +codes remain inspectable and do not authorize guessed changes. Unsupported +actions, unsafe edits, and unsupported backends fail explicitly. Windows +reevaluates the current creator's policy during strict creation. + +`preparePolicyJson` is the only operation that takes observations; creation, +lifecycle, validation, and PTY options accept only the properties shown above, +and any other property throws `MxcError` (`malformed_request`). The SDK keeps +no observations between calls and creates no workload during preparation. +Ordinary warnings are reported in the result or process `warnings` property. + +```typescript +export interface PolicyPreparationOptions { + experimental?: boolean; +} +export type JsonPointer = string; +export type JsonValue = + | null | boolean | number | string + | JsonValue[] | { [key: string]: JsonValue }; +export type PolicyPreparationChange = + | { operation: 'add'; path: JsonPointer; before: null; after: JsonValue } + | { operation: 'remove'; path: JsonPointer; before: JsonValue; after: null } + | { operation: 'replace'; path: JsonPointer; before: JsonValue; after: JsonValue }; +export interface PreparedPolicyRequest { + requestJson: string; + changes: PolicyPreparationChange[]; +} + +export function preparePolicyJson( + requestJson: string, failures: PolicyFailures, options?: PolicyPreparationOptions +): Promise; +export function parsePreparedPolicyRequest(json: string): PreparedPolicyRequest; +export function serializePreparedPolicyRequest(value: PreparedPolicyRequest): string; +``` + +`path` is an RFC 6901 JSON Pointer, not a filesystem path. The empty string +identifies the root; `~0` escapes `~` and `~1` escapes `/`. +Both `before` and `after` are required JSON values, including when null. +For `add`, `before: null` is an absent-value placeholder. For `remove`, +`after: null` is the placeholder. `operation` distinguishes those placeholders +from replacing, adding, or removing a real JSON null. Numbers must be finite; +undefined values, non-JSON objects, and malformed Unicode are rejected. +Numeric change values use JavaScript `number` precision; the candidate +`requestJson` retains its JSON text, while the observations' unsigned 64-bit +values remain lossless decimal strings. + +Preparation accepts at most 8 MiB of input request UTF-8 and returns at most +32 MiB of result JSON containing at most 4096 changes. The candidate request +may exceed 8 MiB when the complete result fits within 32 MiB. Candidate storage +helpers enforce the result size, edit count, and documented fields. Invalid +stored results throw `MxcError` with `code: 'malformed_request'`; malformed +native results reject preparation with `code: 'backend_error'`. Diagnostics do +not echo the supplied request, observations, or result JSON. + +### Models and storage helpers + +All types and helpers below are exported only from +`@microsoft/mxc-sdk/v1/dev`. + +```typescript +export interface PolicyFailures { + formatVersion: number; + results: PolicyFailureResult[]; +} +export interface PolicyFailureResult { + version: number; + minimumReaderVersion: number; + outcome: number; + details: PolicyFailureDetail[]; +} +export interface PolicyFailureDetail { + failureClass: number; + failureReason: number; + requiredAction: number; + resourceKind: number; + valueKind: number; + flags: number; + requestedValue: string; + requiredValue: string; + resource?: number[]; +} + +export function parsePolicyFailures(json: string): PolicyFailures; +export function serializePolicyFailures(value: PolicyFailures): string; +export function mergePolicyFailures(older: PolicyFailures, newer: PolicyFailures): PolicyFailures; +export function policyFailuresFromError(error: unknown): PolicyFailures | undefined; +export function policyFailureResourceText(detail: PolicyFailureDetail): string | undefined; +``` + +`formatVersion` is `1` for observation JSON, independently of native batch +versions and the MXC request JSON's `version`. Native versions, outcomes, detail +codes, and flags are unsigned 32-bit numbers; unrecognized values are +preserved. Each result is one native refusal batch, and its details may not +describe every restriction. +`requestedValue` and `requiredValue` are unsigned 64-bit decimal strings, +including values such as `"18446744073709551615"` that cannot be represented +exactly by a JavaScript `number`. Requested values describe the request Windows +evaluated, not necessarily a later request. + +The Dev subpath also exports frozen value groups. Use names such as +`result.outcome === PolicyFailureOutcomes.Blocked` and +`detail.failureReason === PolicyFailureReasons.CapabilityNotAllowed`. +Fields remain `number` or decimal `string`, not closed unions, so unknown +values survive storage. These names do not establish that preparation is safe. + +`PolicyFailureNativeCodes.PolicyBlocked` is the string `'0x800704EC'` for +`MxcError.nativeCode`: `HRESULT_FROM_WIN32(ERROR_ACCESS_DISABLED_BY_POLICY)`. +The status alone is not a retry decision; also inspect the native operation, +structured outcome, progress, and application stop conditions. + +| Exported object | Read-only members | +|---|---| +| `PolicyFailureOutcomes` | `Unknown: 0`, `Blocked: 1`, `EvaluationFailed: 2` | +| `PolicyFailureClasses` | `None: 0`, `Network: 1`, `Filesystem: 2`, `EnforcementMode: 3` | +| `PolicyFailureReasons` | `None: 0`, `CapabilityNotAllowed: 1`, `AccessExceedsCeiling: 2`, `DenyRequired: 3`, `RequestPathLimit: 4` | +| `PolicyFailureActions` | `None: 0`, `RemoveCapability: 1`, `RestrictFilesystemAccess: 2`, `ReducePathCount: 3` | +| `PolicyFailureResourceKinds` | `None: 0`, `Path: 1`, `Capability: 2` | +| `PolicyFailureValueKinds` | `None: 0`, `Access: 1`, `PathCount: 2` | +| `PolicyFailureAccessValues` | `None: '0'`, `ReadOnly: '1'`, `ReadWrite: '2'`; for requested/required values when `valueKind` is `PolicyFailureValueKinds.Access` | +| `PolicyFailureFlags` | `None: 0`, `HasActionableDetails: 1`, `ResourceComplete: 2`, `ResourceUnavailable: 4`; combine or inspect as a bitmask | + +Only `PolicyFailureAccessValues` uses strings, matching the lossless +`requestedValue` and `requiredValue` fields. The other groups contain numbers. + +`resource` contains the native UTF-16 code units, each between 0 and 65535, +without a terminator. Omitted resources, empty arrays, and malformed UTF-16 +are distinct and survive JSON storage. `policyFailureResourceText` returns +`undefined` for an omitted resource and `''` for an empty one, and throws +`MxcError` with `code: 'malformed_request'` for unpaired surrogates instead of +replacing them. Decoded text does not establish that a resource is complete, +actionable, or safe to use as a path. +Readers normalize explicit `resource: null` to absence, and serialized output +omits it. An empty array remains present. + +Storage helpers validate the documented shape and reject unknown fields and +invalid numeric representations with `MxcError` (`malformed_request`). +`policyFailuresFromError` returns an owned copy of +`MxcError.details.policyFailures`, or `undefined` if the error is not an +`MxcError` or has no observations. A present malformed report throws rather than +being treated as absent. Other error details, including `ffiStatus`, remain +available on the original error. Policy-failure specifics remain structured: +the SDK does not render them into `message`, `toString()`, `stack`, or warning +strings. High-level IT-managed-policy guidance, `operation`, and `nativeCode` +(including HRESULTs) remain available on policy refusals. + +`details` can contain policy resource paths. Default console inspection of an +`MxcError` does not expand `details`, but `JSON.stringify(error)`, object copies +and inspection with `getters` enabled include it. +Filter `details` before logging or serializing an error. + +Store the extracted `policyFailures` value, not the whole `error.details` +object. Its JSON starts with `{ formatVersion: 1, results: [...] }`. Storage +helpers do not preserve companion diagnostics or retry budgets. Persist +application stopping decisions separately if needed across restarts. + +```typescript +import { + parsePolicyFailures, + policyFailuresFromError, + mergePolicyFailures, + preparePolicyJson, + runJson, + serializePolicyFailures, +} from '@microsoft/mxc-sdk/v1/dev'; + +// requestJson, error, earlierFailures, and acceptPolicyChange belong to the application. +const latest = policyFailuresFromError(error); +if (latest !== undefined) { + const merged = earlierFailures === undefined + ? latest : mergePolicyFailures(earlierFailures, latest); + const storedJson = serializePolicyFailures(merged); + // Persist storedJson using application-owned storage. + + // On an application-selected later preparation: + const restored = parsePolicyFailures(storedJson); + const prepared = await preparePolicyJson(requestJson, restored); + if (await acceptPolicyChange(prepared)) { + const result = await runJson(prepared.requestJson); + } +} +``` + +### Owned merging and limits + +`mergePolicyFailures(older, newer)` validates both inputs, deduplicates +identical complete native batches, and returns an owned report in first-seen +order: earlier observations precede fresh ones. Detail order remains part of +batch identity; individual details are not deduplicated. Optional resource +presence remains significant after the documented null normalization, and +decimal strings compare by their unsigned 64-bit numeric value. The output +retains the first batch's values. Invalid inputs or an over-limit merged +report throw `MxcError` with `code: 'malformed_request'` and leave both +inputs unchanged; observations are never truncated. + +Native preparation validates at most 4 MiB of observation JSON, 64 results, +1024 total details, and 262144 total resource accounting units. +Each result allows at most 64 details and 32768 resource accounting units. +Every present resource counts its array length plus one accounting unit, +including an empty array. + +Storage and merge helpers allow one final bounded native batch beyond the +preparation input limits: at most 4.5 MiB JSON, 65 results, +1088 details, and 294912 resource accounting units. The per-result limits +remain unchanged. A report accepted for storage or merging can still exceed +native preparation limits. Persist application stopping decisions separately; +observation storage is not a retry budget or policy approval. diff --git a/docs/api-reference/node/v1/types.md b/docs/api-reference/node/v1/types.md index 83d438ca14..0aae774ce2 100644 --- a/docs/api-reference/node/v1/types.md +++ b/docs/api-reference/node/v1/types.md @@ -418,7 +418,13 @@ export interface LxcConfig { ## `@microsoft/mxc-sdk/v1::MxcError` Typed error thrown by the MXC SDK in response to a native JSON error object. - +Native status decoding preserves backend-specific `details` together with +`details.ffiStatus`. For typed creation-policy observations, use the helpers +in the [V1.Dev reference](dev.md#models-and-storage-helpers). +`details` can contain policy resource paths. Default console inspection of an +`MxcError` does not expand `details`, but `JSON.stringify(error)` and object +copies include it, as does inspection with `getters` enabled. Filter `details` +before logging or serializing an error. ```typescript export class MxcError extends Error { readonly code: ErrorCode; diff --git a/docs/api-reference/rust/v1/README.md b/docs/api-reference/rust/v1/README.md index b3867ba1aa..9d5cbd9921 100644 --- a/docs/api-reference/rust/v1/README.md +++ b/docs/api-reference/rust/v1/README.md @@ -7,7 +7,7 @@ Filesystem discovery helpers and their result/options types use `v1::policy::fil Discovery, probes, telemetry, and policy helpers use the same versioned boundary. - [Operation signatures](api.md) -- [V1.Dev exact-JSON API](https://github.com/microsoft/mxc/blob/main/docs/api-reference/rust/v1/dev.md) +- [V1.Dev MXC request JSON API and policy preparation](https://github.com/microsoft/mxc/blob/main/docs/api-reference/rust/v1/dev.md) - [Types and fields](types.md) - [Cross-SDK requirements](../../README.md) diff --git a/docs/api-reference/rust/v1/api.md b/docs/api-reference/rust/v1/api.md index d5c1fa2431..a7d71b552c 100644 --- a/docs/api-reference/rust/v1/api.md +++ b/docs/api-reference/rust/v1/api.md @@ -6,7 +6,7 @@ Public entrypoint: `mxc_sdk::v1`. [Types](types.md) | [Overview](README.md) Signatures describe the typed consumer API and omit implementation bodies. Callers do not supply JSON or a schema version. -The separate [V1.Dev exact-JSON API](https://github.com/microsoft/mxc/blob/main/docs/api-reference/rust/v1/dev.md) +The separate [V1.Dev MXC request JSON API and policy preparation](https://github.com/microsoft/mxc/blob/main/docs/api-reference/rust/v1/dev.md) accepts caller-authored requests for create-and-run execution and container lifecycle operations. Attached execution is not exposed by the V1 SDK. diff --git a/docs/api-reference/rust/v1/dev.md b/docs/api-reference/rust/v1/dev.md index d9f8c66084..b28da85c65 100644 --- a/docs/api-reference/rust/v1/dev.md +++ b/docs/api-reference/rust/v1/dev.md @@ -1,17 +1,238 @@ -# Rust V1.Dev exact-JSON API +# Rust V1.Dev MXC request JSON API and policy preparation > **Audience:** MXC consumers -Public entrypoint: `mxc_sdk::v1::dev`. Methods pass complete MXC request JSON -unchanged to the in-process engine. The JSON supplies the contract `version`, -`phase`, and container ID where applicable. MXC rejects unregistered versions, -unknown fields, and malformed JSON. +Public entrypoint: `mxc_sdk::v1::dev`. Execution methods pass complete MXC +request JSON unchanged to the in-process engine. The JSON supplies the +contract `version`, `phase`, and container ID where applicable. MXC rejects +unregistered versions, unknown fields, and malformed JSON. -`JsonOptions { experimental: bool }` authorizes experimental backends -independently of the JSON version. `PtyJsonOptions` also carries +`JsonOptions` contains only `experimental: bool`, which authorizes experimental +backends independently of the JSON version. It supports struct literals such +as `JsonOptions { experimental: false }` and lifetime-free `Vec` +storage. `PtyJsonOptions` carries `size: MxcPtySize` (24 rows by 80 columns by default). Result and process types belong to `mxc_sdk::v1`. +## Separate policy preparation + +On native Windows ProcessContainer, a creation refusal can carry structured +policy failures when the OS supports reporting them. `Error.message`, +`Display`, and warnings do not contain the specific policy failures. Use +`PolicyFailures::from_error(&error)` to copy the data from `Error.details`. +The original native operation and status remain authoritative. +Policy refusals retain high-level IT-managed-policy guidance. + +Strict creation returns observations from that attempt only. Use +`prior.merged_with(¤t)` to create an owned, bounded history +without changing either input. It preserves order and deduplicates identical +whole batches, including duplicates within each input; it does not combine or +rewrite individual details. A merge exceeding the storage limits fails without +truncating data. These observations do not enumerate the complete +administrative policy. + +`prepare_policy_json` proposes supported restrictive edits against a freshly +supplied request. It never calls `CreateProcessSecurityEnvironment`, starts a +trace, or executes a workload. It may query host support and use bounded +filesystem probes in the current creator's context. Unsupported actions, +unsafe changes and unsupported backends fail explicitly. Preparation is +supported only for native Windows ProcessContainer create-and-run requests. + +```rust +pub fn prepare_policy_json( + json: &str, + failures: &PolicyFailures, + options: PolicyPreparationOptions, +) -> Result; +``` + +These types are non-exhaustive: + +```rust +pub struct PolicyPreparationOptions { + pub experimental: bool, +} +pub struct PreparedPolicyRequest { + pub request_json: String, + pub changes: Vec, +} +pub struct PolicyChange { + pub operation: PolicyChangeOperation, + pub path: String, + pub before: serde_json::Value, + pub after: serde_json::Value, +} +pub enum PolicyChangeOperation { Add, Remove, Replace } +``` + +Construct `PolicyPreparationOptions` with `Default` and set `experimental` +when needed. It authorizes a backend independently of the JSON contract; +it cannot change the declared `version` or authorize unsupported policy. + +The candidate preserves the original contract version, container metadata, +unrelated settings and optional-field presence. Formatting and property order +can change. Only required authoring edits are projected; arrays may be reported +as a single replacement. If restrictions would change the inferred working +directory, preparation adds `process.cwd` to retain the original directory. +Capture restrictions use `learningMode` and `captureDenials`, not reserved +entries in `processContainer.capabilities`. MXC reparses the candidate and +checks that it reproduces the prospective PSEC and effective policy. + +Preparation returns this JSON shape through `PreparedPolicyRequest::to_json`: + +```json +{ + "requestJson": "{\"version\":\"1.0.0\",\"process\":{\"commandLine\":\"cmd.exe /c exit 0\"},\"processContainer\":{\"capabilities\":[]}}", + "changes": [ + { "operation": "replace", "path": "/processContainer/capabilities", "before": ["registryRead"], "after": [] } + ] +} +``` + +Every change requires both `before` and `after`. `path` is a JSON Pointer: +`~0` escapes `~`, `~1` escapes `/`, and the empty pointer identifies the root. +`Add.before = null` means the original location was absent; +`Remove.after = null` means the candidate location is absent. For `Replace`, +null is an actual JSON null value, not an omitted field. + +`PreparedPolicyRequest::to_json` and `from_json` validate bounded storage data; +restoring a result does not establish approval or validate creation. +Inspect, accept, edit or reject the proposed JSON, then submit the chosen +request through ordinary strict `run_json` or `spawn_json`. Each creation call +makes one native creation attempt and executes only the supplied request. MXC +keeps no observations between calls. Preparation +and caller acceptance are not OS approval: Windows evaluates the current +creator's policy on every creation. + +Observations may be reused with changed requests or creators. Preparation +derives each candidate from that call's original request, including current +proxy endpoints, certificate grants and capture settings. Unknown or incomplete +observations never authorize guessed edits. Ordinary preparation errors do not +echo the request or specific observation values; inspect structured details +when supplied. + +`PolicyFailures`, `PolicyFailureResult` and `PolicyFailureDetail` are +non-exhaustive, so struct literals are unavailable outside the crate; use +`from_json` or `from_error` to obtain values. `PolicyFailures` provides: + +```rust +impl PolicyFailures { + pub fn from_error(error: &Error) -> Result, Error>; + pub fn merged_with(&self, newer: &Self) -> Result; + pub fn to_json(&self) -> Result; + pub fn from_json(json: &str) -> Result; +} +``` + +Its `format_version` identifies observation JSON, independently of each native +result's `version` and `minimum_reader_version` and the MXC request JSON's +`version`. `results` contains `PolicyFailureResult` values with +numeric `outcome` and `details: Vec`. Native numeric +discriminators and flags retain unknown values. `requested_value` and +`required_value` are `u64`, stored as decimal strings in JSON. Optional +`resource` contains UTF-16 units without a terminator; `resource_text()` +decodes strictly without treating invalid text as a repair instruction. +JSON readers treat `resource: null` as absent and omit it when serializing; +an empty array remains distinct. + +Use associated constants for known values, for example +`result.outcome == PolicyFailureResult::OUTCOME_BLOCKED` or +`detail.failure_reason == PolicyFailureDetail::REASON_CAPABILITY_NOT_ALLOWED`. +The fields remain `u32` or `u64`, not closed enums; unknown values survive +inspection and storage. These names do not establish that replay is safe. + +`PolicyFailures::NATIVE_CODE_POLICY_BLOCKED` is the `&str` `"0x800704EC"` for +comparison with `Error.native_code.as_deref()`. It represents +`HRESULT_FROM_WIN32(ERROR_ACCESS_DISABLED_BY_POLICY)`. The caller example +also checks the native operation, structured outcome, progress, and stop limits; +this status alone does not establish that another creation call is safe. + +| Type and constant prefix | Known suffixes and values | +|---|---| +| `PolicyFailureResult::OUTCOME_` (`u32`) | `UNKNOWN = 0`, `BLOCKED = 1`, `EVALUATION_FAILED = 2` | +| `PolicyFailureDetail::CLASS_` (`u32`) | `NONE = 0`, `NETWORK = 1`, `FILESYSTEM = 2`, `ENFORCEMENT_MODE = 3` | +| `PolicyFailureDetail::REASON_` (`u32`) | `NONE = 0`, `CAPABILITY_NOT_ALLOWED = 1`, `ACCESS_EXCEEDS_CEILING = 2`, `DENY_REQUIRED = 3`, `REQUEST_PATH_LIMIT = 4` | +| `PolicyFailureDetail::ACTION_` (`u32`) | `NONE = 0`, `REMOVE_CAPABILITY = 1`, `RESTRICT_FILESYSTEM_ACCESS = 2`, `REDUCE_PATH_COUNT = 3` | +| `PolicyFailureDetail::RESOURCE_KIND_` (`u32`) | `NONE = 0`, `PATH = 1`, `CAPABILITY = 2` | +| `PolicyFailureDetail::VALUE_KIND_` (`u32`) | `NONE = 0`, `ACCESS = 1`, `PATH_COUNT = 2` | +| `PolicyFailureDetail::ACCESS_` (`u64`) | `NONE = 0`, `READ_ONLY = 1`, `READ_WRITE = 2`; for requested/required values when `value_kind` is `VALUE_KIND_ACCESS` | +| `PolicyFailureDetail::FLAG_` (`u32`) | `NONE = 0`, `HAS_ACTIONABLE_DETAILS = 1`, `RESOURCE_COMPLETE = 2`, `RESOURCE_UNAVAILABLE = 4`; combine or inspect as a bitmask | + +Store and prepare only the extracted observations, not the whole error +extension: + +```text +Error.details: { policyFailures: F, companion diagnostics... } +Observations file: F = { formatVersion: 1, results: [...] } +prepare_policy_json(&request_json, &F, options) +``` + +Storage helpers do not retain companion diagnostics or application retry +budgets. Persist stopping decisions separately when they must survive a restart. +Successfully parsing stored observations does not establish that preparation +can safely use them. + +Preparation request input is bounded to 8 MiB of UTF-8. Its complete result is +bounded to 32 MiB and 4,096 changes. A stored candidate request can exceed +8 MiB when the complete result fits the result limit. A result contains every +change or none; no partial candidate or truncated report is returned. + +Observation input is bounded to 4 MiB, 64 batches, 1,024 details and 262,144 +resource accounting units, with 64 details and 32,768 units per native batch. +Each resource counts its length plus one. `PolicyFailures::to_json`, +`from_json`, and `merged_with` allow one final native batch beyond the +preparation input limits: + +| Observation limit | Preparation input | `to_json`, `from_json`, `merged_with` | +|---|---:|---:| +| UTF-8 bytes | 4 MiB | 4.5 MiB | +| Results | 64 | 65 | +| Total details | 1,024 | 1,088 | +| Total resource accounting units | 262,144 | 294,912 | + +A stored result that uses the extra batch cannot be prepared unchanged. +`Error.details.policyFeedbackError` is an object with string `code` +and `reason` fields. Current codes are `unsafeReplay` and +`feedbackLimitExceeded`. Stop automatic retries on its presence, including +unknown codes; do not match diagnostic text. +If fresh diagnostics are unavailable, the error can carry +`policyResultUnavailable`. Retain earlier observations in caller storage; +strict creation does not receive or return that history. Repeated observations +or an unchanged candidate are not progress. + +The [Rust caller example](../../../../src/mxc-sdk/examples/policy_feedback.rs) +uses an application-owned eight-creation-call budget, merges owned observations, +preserves the original request JSON, and stores observations even on terminal +failure. It writes a prepared candidate separately from the input and requires +an exact caller-reviewed candidate snapshot before accepting changes. Keep +stopping decisions and budgets separately when they must survive application +restarts. Run it from `src` with: + +```powershell +cargo run -p mxc-sdk --example policy_feedback -- config.json policy-failures.json candidate.json +# Inspect candidate.json, then save an approved snapshot as approved-candidate.json. +cargo run -p mxc-sdk --example policy_feedback -- config.json policy-failures.json candidate.json approved-candidate.json +``` + +```rust +use mxc_sdk::v1::{ + dev::{self, JsonOptions, PolicyFailures, PreparedPolicyRequest}, + Error, ExecutionResult, +}; + +fn execute_approved( + request_json: &str, + failures: &PolicyFailures, + accept: impl FnOnce(&PreparedPolicyRequest) -> bool, +) -> Result, Error> { + let prepared = dev::prepare_policy_json(request_json, failures, Default::default())?; + if !accept(&prepared) { + return Ok(None); + } + dev::run_json(&prepared.request_json, JsonOptions { experimental: false }).map(Some) +} +``` + `run_json` and `run_in_container_json` return byte-preserving `ExecutionResult` values; a nonzero workload exit or timeout is an outcome, not an API error. Pipe and PTY spawns return owned `MxcProcess` and diff --git a/docs/api-reference/rust/v1/types.md b/docs/api-reference/rust/v1/types.md index 49954feb04..f05a7521d0 100644 --- a/docs/api-reference/rust/v1/types.md +++ b/docs/api-reference/rust/v1/types.md @@ -206,7 +206,11 @@ impl From for Error { sets `details` to `None`. Conversions to and from the internal error preserve structured details, including their JSON value type. Use `error.details.as_deref()` to borrow the value. `Display` omits details and -`Debug` redacts them; do not log sensitive values. +`Debug` redacts only `details`; other fields, such as the message, remain +visible, so do not log sensitive values. A Windows ProcessContainer +creation refusal can carry structured policy failures in `details`; +[V1.Dev policy preparation](dev.md#separate-policy-preparation) documents their +typed extraction. ## `mxc_sdk::v1::ErrorCode` diff --git a/docs/backends/nvx/nvx.md b/docs/backends/nvx/nvx.md index e3e2be1f55..8ed0423032 100644 --- a/docs/backends/nvx/nvx.md +++ b/docs/backends/nvx/nvx.md @@ -222,12 +222,15 @@ and returns its ID in Rust `Error.details.nvxRecovery.sandboxId`, with Use that ID to continue or deprovision through Rust `v1::dev` JSON; do not retry provision. Treat the ID as sensitive; ordinary error formatting omits it. Pre-existing error details are retained under `details.primaryDetails`. -These fields are part of the experimental Rust `v1::dev` behaviour and may -change as the development API evolves. - -The existing C ABI does not expose structured `Error.details`. C-backed Node -and .NET callers therefore do not receive this recovery ID when provision -succeeds but close fails, even though the container remains provisioned. +These fields are part of the experimental development API behavior and may +change as it evolves. + +The C ABI carries structured `Error.details` as JSON, so callers of the C ABI +SDKs (Node and .NET) read `details.nvxRecovery` through `MxcError.details` and +`MxcException.Details`. Keep the container ID out of logs and telemetry: +Node's default console inspection does not expand `details`, but +`JSON.stringify`, inspection with `getters` enabled and the .NET property +expose it. A workload timeout preserves captured partial output and tears down the create-and-run container. Live execution starts its workload deadline only diff --git a/docs/development/architecture/designs/1634-creation-policy-correction/README.md b/docs/development/architecture/designs/1634-creation-policy-correction/README.md index 295d760c87..ac62cf98f9 100644 --- a/docs/development/architecture/designs/1634-creation-policy-correction/README.md +++ b/docs/development/architecture/designs/1634-creation-policy-correction/README.md @@ -14,8 +14,6 @@ then submits its chosen JSON through ordinary strict creation. The application owns storage, merging and retry limits; MXC owns interpreting supported observations and proving each proposed change restrictive. Public declarations and caller examples are in the [API proposal](api.md). -An [exploratory implementation](https://github.com/microsoft/mxc/commit/90ce226f6b0e779abc8dfb541fe5dfb71d5697ae) -illustrates the accepted contract; implementation delivery remains separate. ## Problem and scope diff --git a/docs/development/architecture/designs/1634-creation-policy-correction/api.md b/docs/development/architecture/designs/1634-creation-policy-correction/api.md index 4cd4712f33..626b374e8b 100644 --- a/docs/development/architecture/designs/1634-creation-policy-correction/api.md +++ b/docs/development/architecture/designs/1634-creation-policy-correction/api.md @@ -57,7 +57,7 @@ candidate/change data separately with `PreparedPolicyRequest::to_json`. Approval decisions, original companion diagnostics and retry budgets are application state, not part of either storage value. -The [complete prototype example](https://github.com/microsoft/mxc/blob/90ce226f6b0e779abc8dfb541fe5dfb71d5697ae/src/mxc-sdk/examples/policy_feedback.rs) +The [complete example](../../../../../src/mxc-sdk/examples/policy_feedback.rs) shows bounded caller-controlled retries, storage, exact reviewed-candidate acceptance and output/exit handling. It accepts an unchanged candidate without an approval file; changed candidates require the exact reviewed result. @@ -621,11 +621,10 @@ SDK-specific coverage must include: unsigned64 observation values, UTF-16 distinctions, all-or-nothing limits and companion-state handling. Compare named values with the native schema. -No runtime tests are claimed for this documentation PR. Prototype references -for review are the [Rust](https://github.com/microsoft/mxc/blob/90ce226f6b0e779abc8dfb541fe5dfb71d5697ae/docs/api-reference/rust/v1/dev.md), -[.NET](https://github.com/microsoft/mxc/blob/90ce226f6b0e779abc8dfb541fe5dfb71d5697ae/docs/api-reference/dotnet/v1/dev.md) -and [Node](https://github.com/microsoft/mxc/blob/90ce226f6b0e779abc8dfb541fe5dfb71d5697ae/docs/api-reference/node/v1/dev.md) -Dev contracts. They illustrate the accepted API; implementation delivery remains separate. +The [Rust](../../../../api-reference/rust/v1/dev.md), +[.NET](../../../../api-reference/dotnet/v1/dev.md) +and [Node](../../../../api-reference/node/v1/dev.md) V1.Dev references +document the implemented API. Consumer updates belong in all three SDK READMEs and versioned V1.Dev signature/type references, the API index, and caller-owned storage/resubmission diff --git a/docs/development/build-and-test/ci-validation-infrastructure.md b/docs/development/build-and-test/ci-validation-infrastructure.md index 1e704f90fd..08b5f2d9e3 100644 --- a/docs/development/build-and-test/ci-validation-infrastructure.md +++ b/docs/development/build-and-test/ci-validation-infrastructure.md @@ -158,6 +158,12 @@ script and then to the dispatcher, which has one `switch`/`case` per id. The `process` case requires `wxc-exec --probe` to report an enabled native PSEC contract, then runs a filesystem smoke test, public SDK streaming tests, and the native network and policy suites against the downloaded artifacts. +The SDK suite requires the Dev policy-preparation test to be discovered +and executed. It checks preparation without workload writes, explicit caller +acceptance, strict creation with read-only enforcement, an unrelated writable +control, and changed-request reuse with the original inferred working directory. +The test supplies its own observations; injected native outcomes in the backend +tests cover refusal capture and native-attempt counts. The network and policy suites delegate their focused loopback and enumeration checks to standalone native runners. These retain the host controls and enforcement assertions; enumeration also checks live request-probe agreement @@ -298,15 +304,31 @@ test bundle. The bundle contains native test executables, self-contained .NET tests, and the Node SDK with its Node 24.21.0 runtime; validation hosts do not compile or restore SDK dependencies. -The dispatcher explicitly runs the ignored Rust bidirectional-stdio test, -enables `MXC_E2E_PSEC_CAPABLE_HOST` for the .NET `MxcProcessTests`, and runs the -Node public `run`, `spawn`, and tool-discovery filesystem integration cases. +The dispatcher explicitly runs the ignored Rust bidirectional-stdio and +V1.Dev creation/preparation tests. The preparation case +`dev_json_processcontainer_policy_feedback_restricts_current_request` +must pass and emit `MXC_POLICY_PREPARATION_STRICT_CREATE_OK:` for +`0.9.0-alpha`, `1.0.0` and `1.1.0-alpha`. `--show-output` exposes those captured +success markers without changing the named-test pass gate. Missing markers, +empty selections and skipped required cases fail validation. The bundle +builder requires discovery of the same case and runs +`scripts/ci/test-policy-preparation-test-results.ps1` to check the result gate +offline; this check invokes no backend or workload. + +The dispatcher enables `MXC_E2E_PSEC_CAPABLE_HOST` for the .NET +`MxcProcessTests` and `DevEntryPointTests` and runs the Node public `run`, +`spawn`, and tool-discovery filesystem integration cases. +The managed preparation tests verify owned candidate requests without +launching and lossless storage of native results. Their native-success rows +require Windows and this host opt-in; pure storage rows run without it. Bundle +discovery requires the native preparation fact and both native storage theory +rows, and scheduled validation rejects skipped tests in either class. The tool-discovery case uses a private fixture alongside the packaged Node tests rather than under the host temporary directory. It uses a fake `pwsh.exe`, applies the discovered policy through public `run`, and checks that tool files are readable while a sibling private sentinel remains denied. It -also checks host readability and denial without a grant. The .NET suite checks stdin, -stdout, stderr, large output, cancellation, kill, and timeout behavior. +also checks host readability and denial without a grant. The .NET suite checks +stdin, stdout, stderr, large output, cancellation, kill, and timeout behavior. Required test names and methods must pass; empty selections and skipped required cases fail the job even when the runner exits successfully. diff --git a/docs/development/guides/process-container-adding-os-features.md b/docs/development/guides/process-container-adding-os-features.md index 3f28442008..c718bdaf8f 100644 --- a/docs/development/guides/process-container-adding-os-features.md +++ b/docs/development/guides/process-container-adding-os-features.md @@ -37,7 +37,111 @@ MXC request JSON The PSEC FlatBuffer is the contract between MXC and the OS process security environment. New features must flow from the versioned config contract into the -runtime model and then into that FlatBuffer. +runtime model and then into that FlatBuffer. Policy preparation accepts +creation-policy observations separately from request JSON. + +## Structured creation-policy failures and separate preparation + +Strict creation performs one native creation attempt and executes only the +supplied request. A policy refusal can return owned `PolicyFailures` from that +attempt. Applications merge observations explicitly, then use V1.Dev +`prepare_policy_json` to obtain a candidate request and structured changes. +Preparation never calls `CreateProcessSecurityEnvironment` (CPSE), starts a +trace or executes a workload. The application inspects and accepts, edits or +rejects the candidate before submitting ordinary request JSON through strict +creation. The [Rust Dev reference](../../api-reference/rust/v1/dev.md#separate-policy-preparation) +documents the API, limits and candidate shape, and the +[compiled caller example](../../../src/mxc-sdk/examples/policy_feedback.rs) +shows consumption. The [.NET](../../api-reference/dotnet/v1/dev.md) and +[Node](../../api-reference/node/v1/dev.md) V1.Dev references document the +corresponding preparation APIs. + +When the optional allocation-returning +`Experimental_GetLastProcessSecurityEnvironmentPolicyResult` getter is present +and support flags include `EXPERIMENTAL_PSE_SUPPORT_POLICY_RESULT` (`0x10`), MXC +adds the internal capture flag (`0x2`) to +`CreateProcessSecurityEnvironment` (CPSE). +This enables diagnostic capture only. It does not change the request. Only a +CPSE policy refusal (`HRESULT 0x800704EC`) +queries the getter; retrieval occurs synchronously on the failing native thread +before cleanup, callbacks or another creation can replace its snapshot. +Successful creation and unrelated failures do not query it. + +The copied `PSEP` contract is in +`external/windows-sdk/ExperimentalProcessSecurityEnvironmentPolicyResult.fbs`. +Its Rust bindings and the PSEC bindings use the same regeneration gate +(`scripts/versioning/check-psec-codegen.js`). +The native decoder validates the identifier, requested V1/minimum reader +versions, FlatBuffer structure, and 128 KiB/64-detail/32,768-resource-unit +limits. +Resource arrays contain UTF-16 units without terminators, with one additional +accounting unit per resource. Allocations are independently owned and freed with +`CoTaskMemFree`, including unexpected non-null getter failure outputs. + +Native observations retain their numeric codes, flags, resources and observed +values. Backend-specific data travels through `MxcError.details`, the public +SDK error, owned C error JSON, and the language adapters. Specific failures +never become error or warning text. The generic native operation/status remain +authoritative; missing or invalid diagnostics never imply approval. + +On another policy refusal, the error contains the fresh batch; the caller +uses `PolicyFailures::merged_with` to retain distinct whole batches. +Batches are non-exhaustive: merged observations are not +the entire administrative policy. A later observation describes the reduced +request Windows evaluated, so preparation derives current restrictive +constraints without rewriting the stored observed values. Unknown, incomplete, +contradictory or unsafe instructions fail explicitly. Already-satisfied known +restrictions are harmless; compatible strengthening is applied conservatively. + +Maintainer rules for preparation: + +- Reuse the conservative backend reduction path. Supported edits + remove capabilities and their network/capture producers, lower only matching + existing writable path spellings to read-only, add safe deny exceptions, and + remove exact duplicates to meet path budgets. Preserve restrictive exceptions + and enumeration-only paths. + +- Proxy removal must not activate direct-network permissions that were + ineffective in proxy mode. + +- Filesystem probes use the bounded existing probe facility and the current + creator's context; do not guess at aliases, ambiguous spellings or + unsupported root cases. + +- Treat observations as untrusted data that can be reused across requests or + creators. Every edit must remain restrictive relative to this call's request, + and the selected native tier must enforce it. + +- Project only required authoring edits into the original JSON so it keeps its + contract version, metadata, optional-field presence, unrelated grants and + current runtime proxy, certificate and capture settings. Capture changes use + the dedicated `learningMode`/`captureDenials` fields; never insert reserved + capability names into the authored capability array. + +- Reparse the candidate through the original contract and check it against the + prospective PSEC and effective policy. Return no partial candidate or + truncated changes. Preparation owns no live environment, process or trace + handles. + +The application owns retries, persistence and acceptance. Neither preparation +nor acceptance is approval by Windows, which reevaluates every strict creation. +Successful creation uses the ordinary launch/capture and cleanup path and +returns normal results or handles. Each creation call makes one native creation +attempt and executes only the supplied request. MXC keeps no observations +between calls. + +The Windows CLI uses strict creation and emits the current native observations +in its error envelope. A caller can save an accepted candidate's `requestJson` +as a normal MXC configuration and submit it through the usual CLI invocation. +The CLI does not prepare, merge or persist observations. +Its diagnostic records share stderr with workload output; +JSON shape is not authentication. Prefer direct SDK errors for an automated +retry controller. + +The scheduled ProcessContainer SDK test +`dev_json_processcontainer_policy_feedback_restricts_current_request` and the +injected native/backend tests are described in the +[CI validation runbook](../build-and-test/ci-validation-infrastructure.md). ## Step-by-step diff --git a/external/windows-sdk/ExperimentalProcessSecurityEnvironmentPolicyResult.fbs b/external/windows-sdk/ExperimentalProcessSecurityEnvironmentPolicyResult.fbs new file mode 100644 index 0000000000..cdcb4d27fc --- /dev/null +++ b/external/windows-sdk/ExperimentalProcessSecurityEnvironmentPolicyResult.fbs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// +// Experimental CPSE refusal diagnostics. All tables and numeric enum values are append-only. +// Never change an existing field's type, default or meaning. Add optional fields at the end. +// A producer supporting newer versions must retain a projection for every supported older +// requested version; returning the producer's latest version is not version negotiation. +namespace ExperimentalProcessSecurityEnvironmentPolicyResult; + +enum PolicyResultVersion:uint { V1 = 1 } +// Limits bound retained diagnostics, not the number of constraints policy can enforce. +enum PolicyResultLimit:uint { MaxDetails = 64, MaxResourceChars = 32768, MaxResultBytes = 131072 } + +// Unknown is a reset value, not an allow verdict. EvaluationFailed is a safe refusal +// without a new code for every internal error. +enum PolicyOutcome:uint { Unknown = 0, Blocked = 1, EvaluationFailed = 2 } +// The policy domain, not a Win32/HRESULT category. +enum FailureClass:uint { None = 0, Network = 1, Filesystem = 2, EnforcementMode = 3 } +enum FailureReason:uint { + None = 0, CapabilityNotAllowed = 1, AccessExceedsCeiling = 2, DenyRequired = 3, RequestPathLimit = 4 +} +// Suggested request changes, not authorization. A retry evaluates the then-current policy. +enum RequiredAction:uint { None = 0, RemoveCapability = 1, RestrictFilesystemAccess = 2, ReducePathCount = 3 } +enum ResourceKind:uint { None = 0, Path = 1, Capability = 2 } +enum ValueKind:uint { None = 0, Access = 1, PathCount = 2 } +enum PolicyAccess:ulong { None = 0, ReadOnly = 1, ReadWrite = 2 } +enum PolicyResultFlags:uint { + None = 0, + // A typed remedy is identified; required resource text can still be unavailable. + HasActionableDetails = 1, + // A resource-bearing remedy requires complete text before it can be used. + ResourceComplete = 2, + ResourceUnavailable = 4 +} + +table PolicyDetail { + failure_class:FailureClass; + failure_reason:FailureReason; + required_action:RequiredAction; + resource_kind:ResourceKind; + value_kind:ValueKind; + // Bitmask of PolicyResultFlags. + // Unknown bits and enum values must not be interpreted as a known remedy. + flags:uint; + requested_value:ulong; + required_value:ulong; + // Complete UTF-16 code units, without a NUL terminator. This preserves Windows paths + // losslessly, including code units that cannot be represented as valid UTF-8. + resource:[ushort]; +} + +table PolicyResult { + // The returned contract version, not the latest version supported by the system. + version:uint; + // Old readers can verify a compatible newer snapshot before projecting their own version. + // A future incompatible interpretation must raise this and provide an explicit conversion. + minimum_reader_version:uint = 1; + outcome:PolicyOutcome; + // Bounded, non-exhaustive, ordered repairs. A refusal may have no actionable details. + details:[PolicyDetail]; +} + +root_type PolicyResult; +file_identifier "PSEP"; diff --git a/external/windows-sdk/ProcessSecurityEnvironment.provenance.toml b/external/windows-sdk/ProcessSecurityEnvironment.provenance.toml index c714ece435..34cdcd343f 100644 --- a/external/windows-sdk/ProcessSecurityEnvironment.provenance.toml +++ b/external/windows-sdk/ProcessSecurityEnvironment.provenance.toml @@ -30,6 +30,13 @@ related_trace_abi_pull_request = 16307987 # of autocrlf. Verified by regenerate.ps1 and the CI drift gate. sha256 = "736cacef37a6c97a0cb97c7132da462a3b51811fca8f1e4076cac51085dfa516" +[policy_result] +# ExperimentalProcessSecurityEnvironmentPolicyResult.fbs uses the same pinned +# compiler below, but is a separate OS-owned result contract (PSEP, not PSEC). +source_revision = "5aec16e3838ab5e9542ef4aeb62209ef0635bbb5" +source_path = "onecore/base/appmodel/execmodel/processmodel/lib/schema.psec/ExperimentalProcessSecurityEnvironmentPolicyResult.fbs" +sha256 = "faa6a4e83fb334ffef27e3869139a3736a1859a5659840ba6f8b4b1e0242fa7d" + [tool] # Exact flatc version used to generate the committed bindings. Regeneration # pins this exact version (not a floor) so output is byte-reproducible. diff --git a/scripts/ci/build-windows-sdk-test-bundle.ps1 b/scripts/ci/build-windows-sdk-test-bundle.ps1 index cd15f3056e..30fcdcf336 100644 --- a/scripts/ci/build-windows-sdk-test-bundle.ps1 +++ b/scripts/ci/build-windows-sdk-test-bundle.ps1 @@ -268,7 +268,13 @@ foreach ($test in 'dev_json_one_shot_pty_supports_interactive_input', 'dev_json_ Assert-Discovery 'SDK helper tests' '(?m)^[1-9][0-9]* tests, 0 benchmarks\r?$' { & (Join-Path $inproc 'mxc-sdk-helpers-tests.exe') --list } -foreach ($test in 'streaming_processcontainer_bidirectional_stdio', 'dev_json_processcontainer_capture_and_pipes') { +& (Join-Path $PSScriptRoot 'test-policy-preparation-test-results.ps1') +$requiredProcessContainerTests = @( + 'streaming_processcontainer_bidirectional_stdio' + 'dev_json_processcontainer_capture_and_pipes' + 'dev_json_processcontainer_policy_feedback_restricts_current_request' +) +foreach ($test in $requiredProcessContainerTests) { Assert-Discovery "ProcessContainer $test" "(?m)^${test}: test\r?$" { & (Join-Path $inproc 'mxc-sdk-process-container-streaming-tests.exe') --list --ignored --exact $test } @@ -282,6 +288,18 @@ Assert-Discovery '.NET streaming tests' 'MxcProcessTests\.Streaming_WritesStdin_ Assert-Discovery '.NET exact-JSON creation tests' 'MxcProcessTests\.DevJson_CapturesOutputAndStreamsInput' { & (Join-Path $dotnetOut 'Microsoft.Mxc.Sdk.Tests.exe') -list full -class '*.MxcProcessTests' } +Assert-Discovery '.NET prepared exact-JSON creation test' 'MxcProcessTests\.DevJson_CapturesOutputAndStreamsInput\(version: \\"1\.1\.0-alpha\\", preparePolicy: True\)' { + & (Join-Path $dotnetOut 'Microsoft.Mxc.Sdk.Tests.exe') -list full -preEnumerateTheories -class '*.MxcProcessTests' +} +Assert-Discovery '.NET non-executing preparation test' 'DevEntryPointTests\.PolicyPreparationReturnsOwnedNativeRequestsWithoutLaunching' { + & (Join-Path $dotnetOut 'Microsoft.Mxc.Sdk.Tests.exe') -list full -class '*.DevEntryPointTests' +} +foreach ($test in 'PolicyPreparationStoragePreservesLargeTextWithoutHtmlExpansion', + 'PolicyPreparationStoragePreservesDuplicateCommentMembers') { + Assert-Discovery ".NET native preparation storage $test" "DevEntryPointTests\.$test\(nativePreparation: True\)" { + & (Join-Path $dotnetOut 'Microsoft.Mxc.Sdk.Tests.exe') -list full -preEnumerateTheories -class '*.DevEntryPointTests' + } +} Assert-Discovery '.NET exact-JSON PTY tests' 'MxcContainerIsolationSessionE2ETests\.DevJson_PtySupportsInteractiveInputAndExitStatus' { & (Join-Path $dotnetOut 'Microsoft.Mxc.Sdk.Tests.exe') -list full -class '*.MxcContainerIsolationSessionE2ETests' } @@ -309,6 +327,9 @@ try { Assert-Discovery 'Node exact-JSON creation suite' 'V1.Dev spawnWithPtyJson supports an interactive terminal' { & (Join-Path $OutputDirectory 'node\node.exe') --test --test-force-exit dist/dev.test.js } + Assert-Discovery 'Node prepared exact-JSON creation test' 'V1.Dev prepares filesystem ceilings for caller-accepted strict creation' { + & (Join-Path $OutputDirectory 'node\node.exe') --test --test-force-exit dist/dev.test.js + } Assert-Discovery 'Node WSLC lifecycle suite' 'WSLC state-aware lifecycle E2E' { & (Join-Path $OutputDirectory 'node\node.exe') --test --test-force-exit dist/wslc-state-aware.test.js } diff --git a/scripts/ci/lib/TestSuiteResults.ps1 b/scripts/ci/lib/TestSuiteResults.ps1 index 12a630c09b..90736d858c 100644 --- a/scripts/ci/lib/TestSuiteResults.ps1 +++ b/scripts/ci/lib/TestSuiteResults.ps1 @@ -4,7 +4,8 @@ function Get-LibtestFailure { param( [Parameter(Mandatory)][hashtable]$Run, - [string[]]$RequiredTests = @() + [string[]]$RequiredTests = @(), + [string[]]$RequiredOutputLines = @() ) if ($Run.ExitCode -ne 0) { return "exited $($Run.ExitCode)" } @@ -20,6 +21,12 @@ function Get-LibtestFailure { return "'$name' did not pass" } } + foreach ($line in $RequiredOutputLines) { + $pattern = '^' + [regex]::Escape($line) + '\r?$' + if (-not (Select-String -LiteralPath $Run.Log -Pattern $pattern)) { + return "required output '$line' is missing" + } + } } # Node reports a skipped describe block as a top-level skipped testcase. @@ -51,6 +58,7 @@ function Get-XunitSuiteFailure { [Parameter(Mandatory)][string]$ResultXml, [Parameter(Mandatory)][string[]]$Classes, [string[]]$RequiredMethods = @(), + [string[]]$RequiredOutputText = @(), [switch]$RejectSkipped ) @@ -71,5 +79,13 @@ function Get-XunitSuiteFailure { $failures += "'$method' did not pass" } } + # Pins a specific theory row: its passing output must contain each marker. + foreach ($text in $RequiredOutputText) { + $found = @($selected | Where-Object { + $_.result -eq 'Pass' -and $_.SelectSingleNode('output') -and + $_.SelectSingleNode('output').InnerText.Contains($text) + }) + if ($found.Count -eq 0) { $failures += "required output '$text' is missing" } + } $failures -join '; ' } diff --git a/scripts/ci/run_backend_validation_tests.ps1 b/scripts/ci/run_backend_validation_tests.ps1 index 2859dfa514..0d14f09bed 100644 --- a/scripts/ci/run_backend_validation_tests.ps1 +++ b/scripts/ci/run_backend_validation_tests.ps1 @@ -190,10 +190,17 @@ function Invoke-ProcessContainerStreamingTests { Assert-File -Path $file } - foreach ($rustCase in 'streaming_processcontainer_bidirectional_stdio', 'dev_json_processcontainer_capture_and_pipes') { + foreach ($rustCase in 'streaming_processcontainer_bidirectional_stdio', 'dev_json_processcontainer_capture_and_pipes', 'dev_json_processcontainer_policy_feedback_restricts_current_request') { + $arguments = @('--ignored', '--exact', $rustCase, '--test-threads=1') + $requiredOutput = @() + if ($rustCase -eq 'dev_json_processcontainer_policy_feedback_restricts_current_request') { + $arguments += '--show-output' + $requiredOutput = @('0.9.0-alpha', '1.0.0', '1.1.0-alpha') | + ForEach-Object { "MXC_POLICY_PREPARATION_STRICT_CREATE_OK:$_" } + } $run = Invoke-LoggedNative -FilePath $rustTests -LogName "process-container-rust-$rustCase.log" ` - -ArgumentList @('--ignored', '--exact', $rustCase, '--test-threads=1') - $failure = Get-LibtestFailure -Run $run -RequiredTests $rustCase + -ArgumentList $arguments + $failure = Get-LibtestFailure -Run $run -RequiredTests $rustCase -RequiredOutputLines $requiredOutput if ($failure) { throw "Rust ProcessContainer streaming: $failure" } } @@ -205,13 +212,18 @@ function Invoke-ProcessContainerStreamingTests { $resultXml = Join-Path $env:TEMP 'process-container-dotnet-streaming.xml' if (Test-Path -LiteralPath $resultXml) { Remove-Item -LiteralPath $resultXml } $run = Invoke-LoggedNative -FilePath $dotnetTests -LogName 'process-container-dotnet-streaming.log' ` - -ArgumentList @('-class', '*.MxcProcessTests', '-result-xml', $resultXml) + -ArgumentList @('-class', '*.MxcProcessTests', '-class', '*.DevEntryPointTests', '-result-xml', $resultXml) if ($run.ExitCode -ne 0) { throw ".NET ProcessContainer streaming exited $($run.ExitCode)." } - $failure = Get-XunitSuiteFailure -ResultXml $resultXml -Classes 'MxcProcessTests' -RejectSkipped ` + $failure = Get-XunitSuiteFailure -ResultXml $resultXml -Classes 'MxcProcessTests', 'DevEntryPointTests' -RejectSkipped ` -RequiredMethods @('Streaming_WritesStdin_AndReadsEchoedStdout', 'OutputHeavyChild_DoesNotDeadlock', 'Kill_DuringWaitAsync_Unblocks', 'Wait_EnforcesPolicyTimeout', 'WaitForExitWithOutputAsync_Cancellation_DoesNotDeadlock', - 'DevJson_CapturesOutputAndStreamsInput') + 'DevJson_CapturesOutputAndStreamsInput', 'PolicyPreparationReturnsOwnedNativeRequestsWithoutLaunching', + 'PolicyPreparationStoragePreservesLargeTextWithoutHtmlExpansion', + 'PolicyPreparationStoragePreservesDuplicateCommentMembers') ` + -RequiredOutputText @('MXC_PREPARED_FILESYSTEM_BASELINE_RW_OK', + 'MXC_PREPARED_FILESYSTEM_ACCEPTED:', + 'MXC_PREPARED_FILESYSTEM_STRICT_RUN_SPAWN_OK:1.1.0-alpha:seed=unchanged:permitted=written') if ($failure) { throw ".NET ProcessContainer streaming: $failure" } $junit = Join-Path $env:TEMP 'process-container-node-streaming.junit.xml' @@ -222,7 +234,8 @@ function Invoke-ProcessContainerStreamingTests { 'public tool discovery allows tool reads but denies sibling private files', 'V1.Dev runJson captures output and exit status', 'V1.Dev spawnJson supports bidirectional pipes', - 'V1.Dev spawnWithPtyJson supports an interactive terminal' + 'V1.Dev spawnWithPtyJson supports an interactive terminal', + 'V1.Dev prepares filesystem ceilings for caller-accepted strict creation' ) Push-Location $nodeSuiteRoot try { diff --git a/scripts/ci/test-policy-preparation-test-results.ps1 b/scripts/ci/test-policy-preparation-test-results.ps1 new file mode 100644 index 0000000000..47f3a750ad --- /dev/null +++ b/scripts/ci/test-policy-preparation-test-results.ps1 @@ -0,0 +1,76 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'lib\TestSuiteResults.ps1') +$directory = Join-Path (Get-Location) ('.mxc-preparation-results-' + [guid]::NewGuid().ToString('N')) +$null = New-Item -ItemType Directory -Path $directory +try { + $name = 'dev_json_processcontainer_policy_feedback_restricts_current_request' + $markers = @('0.9.0-alpha', '1.0.0', '1.1.0-alpha') | + ForEach-Object { "MXC_POLICY_PREPARATION_STRICT_CREATE_OK:$_" } + $log = Join-Path $directory 'libtest.log' + $passed = @("test $name ... ok", 'test result: ok. 1 passed; 0 failed; 0 ignored;') + foreach ($case in 'complete', 'missing', 'partial', 'unmatched', 'zero', 'ignored') { + $lines = switch ($case) { + 'complete' { $passed + $markers } + 'missing' { $passed } + 'partial' { $passed + $markers[0] } + 'unmatched' { $passed + @($markers | ForEach-Object { "noise $_" }) } + 'zero' { @('test result: ok. 0 passed; 0 failed; 0 ignored;') + $markers } + 'ignored' { @("test $name ... ignored", 'test result: ok. 0 passed; 0 failed; 1 ignored;') + $markers } + } + Set-Content -LiteralPath $log -Value $lines + $failure = Get-LibtestFailure -Run @{ ExitCode = 0; Log = $log } ` + -RequiredTests $name -RequiredOutputLines $markers + if (($case -eq 'complete') -ne [string]::IsNullOrEmpty($failure)) { + throw "Unexpected preparation result gate for ${case}: $failure" + } + } + Set-Content -LiteralPath $log -Value $passed + if (Get-LibtestFailure -Run @{ ExitCode = 0; Log = $log } -RequiredTests $name) { + throw 'Unchanged callers must not require new output markers.' + } + $outputText = @('MXC_PREPARED_FILESYSTEM_BASELINE_RW_OK', 'MXC_PREPARED_FILESYSTEM_ACCEPTED:', + 'MXC_PREPARED_FILESYSTEM_STRICT_RUN_SPAWN_OK:1.1.0-alpha:seed=unchanged:permitted=written') + $xunit = Join-Path $directory 'xunit.xml' + foreach ($case in 'complete', 'missing', 'skipped') { + $result = if ($case -eq 'skipped') { 'Skip' } else { 'Pass' } + $output = if ($case -eq 'missing') { 'noise' } else { $outputText -join "`n" } + Set-Content -LiteralPath $xunit -Value ('' + + "" + + "") + $failure = Get-XunitSuiteFailure -ResultXml $xunit -Classes 'MxcProcessTests' -RejectSkipped ` + -RequiredMethods 'DevJson_CapturesOutputAndStreamsInput' -RequiredOutputText $outputText + if (($case -eq 'complete') -ne [string]::IsNullOrEmpty($failure)) { + throw "Unexpected .NET prepared-scenario gate for ${case}: $failure" + } + } + $methods = @('PolicyPreparationReturnsOwnedNativeRequestsWithoutLaunching', + 'PolicyPreparationStoragePreservesLargeTextWithoutHtmlExpansion', + 'PolicyPreparationStoragePreservesDuplicateCommentMembers') + foreach ($case in 'complete', 'missing-class', 'missing-method', 'skipped-native') { + $rows = @("") + if ($case -ne 'missing-class') { + if ($case -ne 'missing-method') { + $rows += "" + } + foreach ($method in $methods[1..2]) { + $result = if ($case -eq 'skipped-native') { 'Skip' } else { 'Pass' } + $rows += "" + $rows += "" + } + } + Set-Content -LiteralPath $xunit -Value ( + '' + ($rows -join '') + '') + $failure = Get-XunitSuiteFailure -ResultXml $xunit -Classes 'MxcProcessTests', 'DevEntryPointTests' ` + -RejectSkipped -RequiredMethods (@('DevJson_CapturesOutputAndStreamsInput') + $methods) + if (($case -eq 'complete') -ne [string]::IsNullOrEmpty($failure)) { + throw "Unexpected .NET native preparation gate for ${case}: $failure" + } + } + Write-Host 'Policy preparation result gates passed (14 cases).' +} +finally { + Remove-Item -LiteralPath $directory -Recurse -Force +} diff --git a/scripts/versioning/check-psec-codegen.js b/scripts/versioning/check-psec-codegen.js index df98d3ea29..b0b9f2baf5 100644 --- a/scripts/versioning/check-psec-codegen.js +++ b/scripts/versioning/check-psec-codegen.js @@ -4,11 +4,14 @@ // PSEC generated-contract drift gate. // -// The `mxc_sdk::process_security_environment_spec` module is generated by -// `flatc` from the vendored schema -// external/windows-sdk/ProcessSecurityEnvironment.fbs. This gate: +// The `mxc_sdk::process_security_environment_spec` and +// `mxc_sdk::process_security_environment_policy_result` modules are generated +// by `flatc` from the vendored schemas +// external/windows-sdk/ProcessSecurityEnvironment.fbs and +// external/windows-sdk/ExperimentalProcessSecurityEnvironmentPolicyResult.fbs. +// This gate: // -// 1. verifies the vendored schema SHA-256 matches the recorded provenance +// 1. verifies each vendored schema SHA-256 matches the recorded provenance // (external/windows-sdk/ProcessSecurityEnvironment.provenance.toml); // 2. verifies every committed generated file carries the flatc "do not // modify." header (a hand-edit guard); @@ -35,22 +38,46 @@ const { join, relative, sep } = require("path"); const { execFileSync } = require("child_process"); const repoRoot = join(__dirname, "..", ".."); -const schemaPath = join(repoRoot, "external", "windows-sdk", "ProcessSecurityEnvironment.fbs"); const provenancePath = join( repoRoot, "external", "windows-sdk", "ProcessSecurityEnvironment.provenance.toml" ); -const moduleDir = join( - repoRoot, - "src", - "mxc-sdk", - "src", - "core", - "process_security_environment_spec" -); -const committedSrcDir = moduleDir; +// Each contract maps a vendored schema to its provenance section and SDK module. +const contracts = [ + { + schemaPath: join(repoRoot, "external", "windows-sdk", "ProcessSecurityEnvironment.fbs"), + section: "schema", + committedSrcDir: join( + repoRoot, + "src", + "mxc-sdk", + "src", + "core", + "process_security_environment_spec" + ), + layout: "process_security_environment_layout", + }, + { + schemaPath: join( + repoRoot, + "external", + "windows-sdk", + "ExperimentalProcessSecurityEnvironmentPolicyResult.fbs" + ), + section: "policy_result", + committedSrcDir: join( + repoRoot, + "src", + "mxc-sdk", + "src", + "core", + "process_security_environment_policy_result" + ), + layout: "experimental_process_security_environment_policy_result", + }, +]; function fail(msg) { console.error("PSEC codegen check FAILED:"); @@ -95,8 +122,12 @@ function readProvenance() { } catch (e) { fail(`could not read provenance ${provenancePath}: ${e.message}`); } - const schemaSha = tomlString(tomlSection(text, "schema"), "sha256"); - if (!schemaSha) fail(`provenance missing [schema].sha256: ${provenancePath}`); + const schemaShas = {}; + for (const { section } of contracts) { + const sha = tomlString(tomlSection(text, section), "sha256"); + if (!sha) fail(`provenance missing [${section}].sha256: ${provenancePath}`); + schemaShas[section] = sha.toLowerCase(); + } const flatcVersion = tomlString(tomlSection(text, "tool"), "flatc_version"); if (!flatcVersion) fail(`provenance missing [tool].flatc_version: ${provenancePath}`); const asset = (osName) => { @@ -106,7 +137,7 @@ function readProvenance() { return name && sha256 ? { name, sha256: sha256.toLowerCase() } : null; }; return { - schemaSha: schemaSha.toLowerCase(), + schemaShas, flatcVersion, assets: { linux: asset("linux"), windows: asset("windows") }, }; @@ -214,8 +245,8 @@ flatbuffers = "25" path = "src/mod.rs" `; -function regenerate(flatc, tmp) { - const genCrate = join(tmp, "gen"); +function regenerate(flatc, tmp, contract) { + const genCrate = join(tmp, "gen-" + contract.section); fs.mkdirSync(genCrate, { recursive: true }); execFileSync( flatc, @@ -228,7 +259,7 @@ function regenerate(flatc, tmp) { "--gen-all", "-o", genCrate, - schemaPath, + contract.schemaPath, ], { stdio: ["ignore", "ignore", "inherit"] } ); @@ -237,8 +268,8 @@ function regenerate(flatc, tmp) { fs.mkdirSync(genSrc); fs.renameSync(join(genCrate, "mod.rs"), join(genSrc, "mod.rs")); fs.renameSync( - join(genCrate, "process_security_environment_layout"), - join(genSrc, "process_security_environment_layout") + join(genCrate, contract.layout), + join(genSrc, contract.layout) ); const libRs = join(genSrc, "mod.rs"); @@ -269,17 +300,13 @@ function collectRel(dir) { return out.sort(); } -function collectGeneratedRel(dir) { - return collectRel(dir).filter( - (rel) => - rel === "mod.rs" || - rel.startsWith("process_security_environment_layout/") - ); +function collectGeneratedRel(dir, layout) { + return collectRel(dir).filter((rel) => rel === "mod.rs" || rel.startsWith(layout + "/")); } -function diffTrees(committed, generated) { - const a = collectGeneratedRel(committed); - const b = collectGeneratedRel(generated); +function diffTrees(committed, generated, layout) { + const a = collectGeneratedRel(committed, layout); + const b = collectGeneratedRel(generated, layout); const setA = new Set(a); const setB = new Set(b); const onlyCommitted = a.filter((x) => !setB.has(x)); @@ -301,7 +328,7 @@ function diffTrees(committed, generated) { while (line < c.length && line < g.length && c[line] === g[line]) line++; const show = (arr) => (line < arr.length ? JSON.stringify(arr[line]) : ""); fail( - `committed generated output is stale at src/${rel}.\n` + + `committed generated output is stale at ${relative(repoRoot, join(committed, rel))}.\n` + ` First difference at line ${line + 1}:\n` + ` committed: ${show(c)}\n` + ` regenerated: ${show(g)}\n` + @@ -316,23 +343,25 @@ function diffTrees(committed, generated) { // ============================================================================ const provInfo = readProvenance(); -// --- 1. Schema hash matches provenance -------------------------------------- -let schemaBytes; -try { - schemaBytes = fs.readFileSync(schemaPath); -} catch (e) { - fail(`could not read schema ${schemaPath}: ${e.message}`); -} -const actualSchemaSha = sha256Hex(lfNormalize(schemaBytes)); -if (actualSchemaSha !== provInfo.schemaSha) { - fail( - `vendored schema hash drifted from provenance.\n` + - ` schema: ${schemaPath}\n` + - ` expected: ${provInfo.schemaSha} (${provenancePath})\n` + - ` actual: ${actualSchemaSha}\n` + - ` If you intentionally refreshed the schema, update the provenance\n` + - ` (sha256 + source build) and regenerate the bindings (see the crate README).` - ); +// --- 1. Schema hashes match provenance -------------------------------------- +for (const { schemaPath, section } of contracts) { + let schemaBytes; + try { + schemaBytes = fs.readFileSync(schemaPath); + } catch (e) { + fail(`could not read schema ${schemaPath}: ${e.message}`); + } + const actualSchemaSha = sha256Hex(lfNormalize(schemaBytes)); + if (actualSchemaSha !== provInfo.schemaShas[section]) { + fail( + `vendored schema hash drifted from provenance.\n` + + ` schema: ${schemaPath}\n` + + ` expected: ${provInfo.schemaShas[section]} (${provenancePath})\n` + + ` actual: ${actualSchemaSha}\n` + + ` If you intentionally refreshed the schema, update the provenance\n` + + ` (sha256 + source build) and regenerate the bindings (see the crate README).` + ); + } } // --- 2. Generated files carry the "do not modify" header -------------------- @@ -340,30 +369,34 @@ function isMarkedGenerated(text) { const head = text.slice(0, 400).toLowerCase(); return head.includes("@generated") && head.includes("do not modify"); } -if (!fs.existsSync(committedSrcDir)) { - fail(`generated source directory not found: ${committedSrcDir}`); -} -const committedFiles = collectGeneratedRel(committedSrcDir); -if (committedFiles.length === 0) { - fail(`no generated files found under ${committedSrcDir}`); -} -const unmarked = committedFiles.filter( - (rel) => !isMarkedGenerated(fs.readFileSync(join(committedSrcDir, rel), "utf8")) -); -if (unmarked.length > 0) { - fail( - "generated file(s) are missing the flatc 'do not modify' header (hand-edited?):\n" + - unmarked.map((f) => " src/" + f).join("\n") +for (const { committedSrcDir, layout } of contracts) { + if (!fs.existsSync(committedSrcDir)) { + fail(`generated source directory not found: ${committedSrcDir}`); + } + const committedFiles = collectGeneratedRel(committedSrcDir, layout); + if (committedFiles.length === 0) { + fail(`no generated files found under ${committedSrcDir}`); + } + const unmarked = committedFiles.filter( + (rel) => !isMarkedGenerated(fs.readFileSync(join(committedSrcDir, rel), "utf8")) ); + if (unmarked.length > 0) { + fail( + "generated file(s) are missing the flatc 'do not modify' header (hand-edited?):\n" + + unmarked.map((f) => " " + relative(repoRoot, join(committedSrcDir, f))).join("\n") + ); + } } // --- 3. Real regenerate-and-diff with the exact pinned flatc ---------------- const tmpBase = fs.mkdtempSync(join(os.tmpdir(), "mxc-psec-")); -let comparedCount; +let comparedCount = 0; try { const flatc = acquireFlatc(provInfo, tmpBase); - const genSrc = regenerate(flatc, tmpBase); - comparedCount = diffTrees(committedSrcDir, genSrc); + for (const contract of contracts) { + const genSrc = regenerate(flatc, tmpBase, contract); + comparedCount += diffTrees(contract.committedSrcDir, genSrc, contract.layout); + } } finally { fs.rmSync(tmpBase, { recursive: true, force: true }); } @@ -382,7 +415,7 @@ try { } console.log( - `PSEC codegen OK: schema matches provenance (sha256 ${provInfo.schemaSha.slice(0, 12)}…), ` + + `PSEC codegen OK: ${contracts.length} schemas match provenance, ` + `regenerated with pinned flatc ${provInfo.flatcVersion} and diffed ${comparedCount} files ` + `(no drift), all carry the do-not-modify header, and the SDK module compiles.` ); diff --git a/scripts/versioning/regenerate-psec.ps1 b/scripts/versioning/regenerate-psec.ps1 index b446f60ffd..b7da97062d 100644 --- a/scripts/versioning/regenerate-psec.ps1 +++ b/scripts/versioning/regenerate-psec.ps1 @@ -1,19 +1,21 @@ <# .SYNOPSIS Regenerates the FlatBuffers Rust bindings for the - `mxc_sdk::process_security_environment_spec` module, reproducibly. + `mxc_sdk::process_security_environment_spec` and + `mxc_sdk::process_security_environment_policy_result` modules, reproducibly. .DESCRIPTION Runs `flatc` against external/windows-sdk/ProcessSecurityEnvironment.fbs and - rewrites the output into the SDK module layout. Before generating it: - * validates the vendored schema's SHA-256 against the recorded provenance + external/windows-sdk/ExperimentalProcessSecurityEnvironmentPolicyResult.fbs + and rewrites the output into the SDK module layout. Before generating it: + * validates each vendored schema's SHA-256 against the recorded provenance (external/windows-sdk/ProcessSecurityEnvironment.provenance.toml), and * pins the EXACT flatc version recorded in that provenance file, so the generated output is byte-reproducible. The generated files are checked in and must NOT be hand-edited; rerun this script instead. The CI drift gate (scripts/versioning/check-psec-codegen.js) - fails if the committed schema or generated module drifts. + fails if a committed schema or generated module drifts. .PARAMETER Flatc Path to flatc.exe. Defaults to "flatc.exe" (must be on PATH). @@ -34,13 +36,28 @@ if (-not $repoRoot) { } Set-Location $repoRoot -$moduleDir = Join-Path $repoRoot "src\mxc-sdk\src\core\process_security_environment_spec" -$generatedDir = Join-Path $moduleDir ".generated-$PID" -$fbs = "external\windows-sdk\ProcessSecurityEnvironment.fbs" $provenanceFile = "external\windows-sdk\ProcessSecurityEnvironment.provenance.toml" -if (-not (Test-Path $fbs)) { - throw "FlatBuffers schema not found: $fbs" +# Each contract maps a vendored schema to its provenance section and SDK module. +$contracts = @( + @{ + Fbs = "external\windows-sdk\ProcessSecurityEnvironment.fbs" + Section = "schema" + Module = "process_security_environment_spec" + Layout = "process_security_environment_layout" + }, + @{ + Fbs = "external\windows-sdk\ExperimentalProcessSecurityEnvironmentPolicyResult.fbs" + Section = "policy_result" + Module = "process_security_environment_policy_result" + Layout = "experimental_process_security_environment_policy_result" + } +) + +foreach ($contract in $contracts) { + if (-not (Test-Path $contract.Fbs)) { + throw "FlatBuffers schema not found: $($contract.Fbs)" + } } if (-not (Test-Path $provenanceFile)) { throw "Provenance file not found: $provenanceFile" @@ -49,29 +66,34 @@ if (-not (Test-Path $Flatc) -and -not (Get-Command $Flatc -ErrorAction SilentlyC throw "flatc not found: $Flatc. Download from https://github.com/google/flatbuffers/releases" } -# --- Read pinned toolchain + expected schema hash from provenance ------------ +# --- Read pinned toolchain from provenance ----------------------------------- $provenance = Get-Content $provenanceFile -Raw $pinnedFlatc = [regex]::Match($provenance, 'flatc_version\s*=\s*"([^"]+)"') -$expectedHash = [regex]::Match($provenance, 'sha256\s*=\s*"([^"]+)"') if (-not $pinnedFlatc.Success) { throw "Could not read flatc_version from $provenanceFile" } -if (-not $expectedHash.Success) { - throw "Could not read schema sha256 from $provenanceFile" -} $pinnedFlatcVersion = $pinnedFlatc.Groups[1].Value -$expectedSchemaHash = $expectedHash.Groups[1].Value.ToLower() - -# --- Validate the vendored schema hash matches provenance -------------------- -# Hash the LF-normalized content so the check is checkout-independent (autocrlf). -$schemaText = (Get-Content $fbs -Raw) -replace "`r`n", "`n" -$schemaBytes = [System.Text.Encoding]::UTF8.GetBytes($schemaText) -$sha = [System.Security.Cryptography.SHA256]::Create() -$actualSchemaHash = (($sha.ComputeHash($schemaBytes) | ForEach-Object { $_.ToString("x2") }) -join "") -if ($actualSchemaHash -ne $expectedSchemaHash) { - throw "Schema hash mismatch for $fbs.`n expected (provenance): $expectedSchemaHash`n actual (on disk): $actualSchemaHash`nIf you intentionally refreshed the schema, update $provenanceFile (sha256 + source revision) first." + +# --- Validate each vendored schema hash matches provenance ------------------- +foreach ($contract in $contracts) { + $fbs = $contract.Fbs + $section = [regex]::Match($provenance, '(?ms)^\[' + [regex]::Escape($contract.Section) + '\]\s*(.*?)(?=^\[|\z)') + $expectedHash = [regex]::Match($section.Groups[1].Value, 'sha256\s*=\s*"([^"]+)"') + if (-not $expectedHash.Success) { + throw "Could not read [$($contract.Section)] sha256 from $provenanceFile" + } + $expectedSchemaHash = $expectedHash.Groups[1].Value.ToLower() + + # Hash the LF-normalized content so the check is checkout-independent (autocrlf). + $schemaText = (Get-Content $fbs -Raw) -replace "`r`n", "`n" + $schemaBytes = [System.Text.Encoding]::UTF8.GetBytes($schemaText) + $sha = [System.Security.Cryptography.SHA256]::Create() + $actualSchemaHash = (($sha.ComputeHash($schemaBytes) | ForEach-Object { $_.ToString("x2") }) -join "") + if ($actualSchemaHash -ne $expectedSchemaHash) { + throw "Schema hash mismatch for $fbs.`n expected (provenance): $expectedSchemaHash`n actual (on disk): $actualSchemaHash`nIf you intentionally refreshed the schema, update $provenanceFile (sha256 + source revision) first." + } + Write-Host "Schema hash OK for $fbs ($expectedSchemaHash)" -ForegroundColor Cyan } -Write-Host "Schema hash OK ($expectedSchemaHash)" -ForegroundColor Cyan # --- Pin the EXACT flatc version for reproducible output --------------------- $versionOutput = (& $Flatc --version) 2>&1 | Out-String @@ -85,48 +107,54 @@ if ($flatcVersion -ne $pinnedFlatcVersion) { } Write-Host "Using pinned flatc version $flatcVersion" -ForegroundColor Cyan -Write-Host "Running flatc..." -ForegroundColor Cyan -try { - New-Item -ItemType Directory -Path $generatedDir | Out-Null - & $Flatc ` - --rust --gen-object-api --force-empty --no-prefix --rust-module-root-file --gen-all ` - -o $generatedDir ` - $fbs - if ($LASTEXITCODE -ne 0) { - throw "flatc failed with exit code $LASTEXITCODE" - } - - Write-Host "Replacing generated module files..." -ForegroundColor Cyan - $moduleRoot = Join-Path $moduleDir "mod.rs" - $layoutDir = Join-Path $moduleDir "process_security_environment_layout" - if (Test-Path $moduleRoot) { - Remove-Item $moduleRoot -Force - } - if (Test-Path $layoutDir) { - Remove-Item $layoutDir -Recurse -Force - } - Move-Item (Join-Path $generatedDir "mod.rs") $moduleRoot - Move-Item (Join-Path $generatedDir "process_security_environment_layout") $layoutDir - - Write-Host "Patching mod.rs (lint suppression)..." -ForegroundColor Cyan - (Get-Content $moduleRoot) ` - -replace '// @generated', "// @generated`n#![allow(unused_imports, non_snake_case, non_camel_case_types, clippy::all)]" | - Set-Content $moduleRoot +foreach ($contract in $contracts) { + $fbs = $contract.Fbs + $moduleDir = Join-Path $repoRoot "src\mxc-sdk\src\core\$($contract.Module)" + $generatedDir = Join-Path $moduleDir ".generated-$PID" - Write-Host "Formatting with cargo fmt..." -ForegroundColor Cyan - Push-Location src + Write-Host "Running flatc for $fbs..." -ForegroundColor Cyan try { - & cargo fmt -p mxc-sdk + New-Item -ItemType Directory -Path $generatedDir | Out-Null + & $Flatc ` + --rust --gen-object-api --force-empty --no-prefix --rust-module-root-file --gen-all ` + -o $generatedDir ` + $fbs if ($LASTEXITCODE -ne 0) { - throw "cargo fmt failed with exit code $LASTEXITCODE" + throw "flatc failed with exit code $LASTEXITCODE" + } + + Write-Host "Replacing generated module files..." -ForegroundColor Cyan + $moduleRoot = Join-Path $moduleDir "mod.rs" + $layoutDir = Join-Path $moduleDir $contract.Layout + if (Test-Path $moduleRoot) { + Remove-Item $moduleRoot -Force } + if (Test-Path $layoutDir) { + Remove-Item $layoutDir -Recurse -Force + } + Move-Item (Join-Path $generatedDir "mod.rs") $moduleRoot + Move-Item (Join-Path $generatedDir $contract.Layout) $layoutDir + + Write-Host "Patching mod.rs (lint suppression)..." -ForegroundColor Cyan + (Get-Content $moduleRoot) ` + -replace '// @generated', "// @generated`n#![allow(unused_imports, non_snake_case, non_camel_case_types, clippy::all)]" | + Set-Content $moduleRoot } finally { - Pop-Location + if (Test-Path $generatedDir) { + Remove-Item $generatedDir -Recurse -Force + } } +} - Write-Host "Done. Regenerated bindings in $moduleDir" -ForegroundColor Green -} finally { - if (Test-Path $generatedDir) { - Remove-Item $generatedDir -Recurse -Force +Write-Host "Formatting with cargo fmt..." -ForegroundColor Cyan +Push-Location src +try { + & cargo fmt -p mxc-sdk + if ($LASTEXITCODE -ne 0) { + throw "cargo fmt failed with exit code $LASTEXITCODE" } +} finally { + Pop-Location } + +Write-Host "Done. Regenerated bindings for $($contracts.Module -join ', ')" -ForegroundColor Green diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.AotSmokeTest/Program.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.AotSmokeTest/Program.cs index 0eb0d72a1f..a1ba18c9c6 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.AotSmokeTest/Program.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.AotSmokeTest/Program.cs @@ -13,6 +13,8 @@ using System.Text.Json; using System.Text.Json.Nodes; +using System.Text; +using System.Text.Encodings.Web; using Microsoft.Mxc.Sdk; using Microsoft.Mxc.Sdk.V1; using Dev = Microsoft.Mxc.Sdk.V1.Dev; @@ -36,7 +38,153 @@ static void CheckRoundTrip(T value, Func check, string name) var initialPtySize = new MxcPtySize(40, 120); CheckRoundTrip(new Dev.JsonOptions { Experimental = true }, - x => x.Experimental, "dev JSON options experimental authorization"); + x => x.Experimental, "dev JSON execution options"); +CheckRoundTrip(new Dev.PolicyPreparationOptions { Experimental = true }, + x => x.Experimental, "policy preparation options"); +var policyFailures = new Dev.PolicyFailures +{ + Results = + [ + new() + { + Version = 99, + MinimumReaderVersion = 98, + Outcome = uint.MaxValue, + Details = + [ + new() + { + FailureClass = uint.MaxValue, + FailureReason = 2, + RequiredAction = 2, + ResourceKind = 1, + ValueKind = 1, + Flags = uint.MaxValue, + RequestedValue = ulong.MaxValue, + RequiredValue = 9_007_199_254_740_993, + Resource = [0xD800, 0], + }, + new() { Resource = [] }, + new(), + ], + }, + new() { Outcome = 2 }, + ], +}; +var storedFailures = policyFailures.ToJson(); +var restoredFailures = Dev.PolicyFailures.FromJson(storedFailures); +Check(restoredFailures.Results.Length == 2 + && restoredFailures.Results[0].Version == 99 + && restoredFailures.Results[0].MinimumReaderVersion == 98 + && restoredFailures.Results[0].Outcome == uint.MaxValue + && restoredFailures.Results[0].Details[0].RequestedValue == ulong.MaxValue + && restoredFailures.Results[0].Details[0].RequiredValue == 9_007_199_254_740_993 + && restoredFailures.Results[0].Details[0].Resource!.SequenceEqual(new ushort[] { 0xD800, 0 }) + && restoredFailures.Results[0].Details[1].GetResourceText() == string.Empty + && restoredFailures.Results[0].Details[2].GetResourceText() is null, + "public policy-failure JSON helpers preserve batches, values, and resource presence"); +try +{ + _ = restoredFailures.Results[0].Details[0].GetResourceText(); + Check(false, "resource text rejects malformed UTF-16"); +} +catch (DecoderFallbackException) +{ +} +CheckRoundTrip(policyFailures, x => x.ToJson() == storedFailures, "policy failures root"); +CheckRoundTrip(policyFailures.Results[0], + x => x.Version == 99 && x.Details[0].RequestedValue == ulong.MaxValue, "native policy-result root"); +CheckRoundTrip(policyFailures.Results[0].Details[0], + x => x.Flags == uint.MaxValue && x.RequiredValue == 9_007_199_254_740_993, "native policy-detail root"); +var mergedFailures = restoredFailures.MergedWith(Dev.PolicyFailures.FromJson(storedFailures)); +Check(mergedFailures.ToJson() == storedFailures + && !ReferenceEquals(mergedFailures.Results[0].Details[0].Resource, + restoredFailures.Results[0].Details[0].Resource), + "public policy-failure merge deduplicates whole batches into owned storage"); +Dev.PreparedPolicyRequest preparedPolicy; +using (var document = JsonDocument.Parse("""{"null":null,"value":{"large":18446744073709551615}}""")) +{ + var root = document.RootElement; + preparedPolicy = new Dev.PreparedPolicyRequest + { + RequestJson = """{"version":"1.1.0-alpha","process":{"commandLine":"echo"}}""", + Changes = + [ + new() + { + Operation = Dev.PolicyChangeOperation.Add, Path = "/new", + Before = root.GetProperty("null"), After = root.GetProperty("value"), + }, + new() + { + Operation = Dev.PolicyChangeOperation.Remove, Path = "/old", + Before = root.GetProperty("value"), After = root.GetProperty("null"), + }, + new() + { + Operation = Dev.PolicyChangeOperation.Replace, Path = "/a~1b/~0field", + Before = root.GetProperty("null"), After = root.GetProperty("null"), + }, + ], + }; +} +var storedPreparation = preparedPolicy.ToJson(); +Check(Dev.PreparedPolicyRequest.FromJson(storedPreparation).ToJson() == storedPreparation, + "public prepared-policy storage retains owned JSON values and required null placeholders"); +const string annotatedRequest = """ {"_comment":{"x":1,"x":2},"process":{"commandLine":"<>&'"}} """; +var annotatedStorage = "{\"requestJson\":\"" + + JsonEncodedText.Encode(annotatedRequest, JavaScriptEncoder.UnsafeRelaxedJsonEscaping) + + "\",\"changes\":[]}"; +var annotatedPreparation = Dev.PreparedPolicyRequest.FromJson(annotatedStorage); +Check(annotatedPreparation.ToJson() == annotatedStorage + && annotatedPreparation.RequestJson == annotatedRequest, + "preparation storage preserves arbitrary comment JSON text and compact escaping"); +Check(MxcJson.Serialize("<", MxcJson.Options) == "\"\\u003C\"", + "preparation storage leaves the shared SDK encoder unchanged"); +foreach (var missingValue in new[] +{ + """{"requestJson":"{}","changes":[{"operation":"add","path":"/x","after":true}]}""", + """{"requestJson":"{}","changes":[{"operation":"remove","path":"/x","before":true}]}""", +}) +{ + try + { + _ = Dev.PreparedPolicyRequest.FromJson(missingValue); + Check(false, "missing policy change values are rejected"); + } + catch (JsonException error) + { + Check(error.InnerException is null, "missing policy change values have safe validation errors"); + } +} +CheckRoundTrip(preparedPolicy, x => x.ToJson() == storedPreparation, "prepared-policy request root"); +CheckRoundTrip(preparedPolicy.Changes[0], + x => x.Before.ValueKind == JsonValueKind.Null + && x.After.GetProperty("large").GetUInt64() == ulong.MaxValue, "policy change root"); +CheckRoundTrip(Dev.PolicyChangeOperation.Replace, + x => x == Dev.PolicyChangeOperation.Replace, "policy change operation root"); +using (var document = JsonDocument.Parse(storedPreparation)) +{ + Check(document.RootElement.GetProperty("changes").EnumerateArray() + .Select(change => change.GetProperty("operation").GetString()) + .SequenceEqual(["add", "remove", "replace"]), "lowercase policy change operations"); + Check(document.RootElement.GetProperty("changes").EnumerateArray() + .All(change => change.TryGetProperty("before", out _) && change.TryGetProperty("after", out _)), + "both policy change values are serialized even when JSON null"); +} +var errorDetailsJson = $$$"""{"policyFailures":{{{storedFailures}}},"other":{"retained":true}}"""; +CheckRoundTrip(MxcJson.Deserialize(errorDetailsJson), + x => x.GetProperty("other").GetProperty("retained").GetBoolean(), "generic backend error details"); +MxcException policyError; +using (var document = JsonDocument.Parse(errorDetailsJson)) +{ + policyError = new MxcException(ErrorCode.BackendError, "creation refused", + "PSEC.Create", "0x80070005", null, document.RootElement); +} +Check(Dev.PolicyFailures.FromException(policyError)?.ToJson() == storedFailures + && policyError.Details!.Value.GetProperty("other").GetProperty("retained").GetBoolean() + && policyError.Message == "creation refused", + "public policy-failure extraction retains generic details independently of the original document"); CheckRoundTrip(new Dev.PtyJsonOptions(), x => !x.Experimental && x.Size is null, "dev PTY options without initial dimensions"); CheckRoundTrip(new Dev.PtyJsonOptions { Experimental = true, Size = initialPtySize }, diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/DevEntryPointTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/DevEntryPointTests.cs index 5d9937413e..ca1984c5a2 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/DevEntryPointTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/DevEntryPointTests.cs @@ -2,6 +2,9 @@ // Licensed under the MIT License. using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; using Dev = Microsoft.Mxc.Sdk.V1.Dev; using Microsoft.Mxc.Sdk.V1; using Xunit; @@ -10,6 +13,10 @@ namespace Microsoft.Mxc.Sdk.Tests; public class DevEntryPointTests { + private static bool HostCanPreparePolicy => + OperatingSystem.IsWindows() + && Environment.GetEnvironmentVariable("MXC_E2E_PSEC_CAPABLE_HOST") == "1"; + [Fact] public async Task DevEntryPointsRejectInvalidDocumentsWithoutLaunching() { @@ -175,5 +182,592 @@ public void DevOptionsAreClosedAndTerminalControlsStayWithPtyOperations() Assert.True(typeof(Dev.JsonOptions).IsSealed); Assert.True(typeof(Dev.PtyJsonOptions).IsSealed); Assert.False(typeof(Dev.JsonOptions).IsAssignableFrom(typeof(Dev.PtyJsonOptions))); + Assert.Null(typeof(Dev.PtyJsonOptions).GetProperty("PolicyFailures")); + } + + [Fact] + public void PolicyFailureStoragePreservesUnknownValuesBatchBoundariesAndResourcePresence() + { + ushort[] malformedResource = [0xD800, 0, 0xDC00]; + var failures = new Dev.PolicyFailures + { + Results = + [ + new() + { + Version = 99, + MinimumReaderVersion = 98, + Outcome = uint.MaxValue, + Details = + [ + new() + { + FailureClass = uint.MaxValue, + FailureReason = uint.MaxValue - 1, + RequiredAction = uint.MaxValue - 2, + ResourceKind = uint.MaxValue - 3, + ValueKind = uint.MaxValue - 4, + Flags = uint.MaxValue, + RequestedValue = ulong.MaxValue, + RequiredValue = 9_007_199_254_740_993, + Resource = malformedResource, + }, + new() { Resource = [] }, + new(), + new() { Resource = [0xD83D, 0xDE00] }, + ], + }, + new() + { + Outcome = Dev.PolicyFailureOutcomes.Blocked, + Details = + [ + new() + { + FailureClass = Dev.PolicyFailureClasses.Filesystem, + FailureReason = Dev.PolicyFailureReasons.AccessExceedsCeiling, + RequiredAction = Dev.PolicyFailureActions.RestrictFilesystemAccess, + ResourceKind = Dev.PolicyFailureResourceKinds.Path, + ValueKind = Dev.PolicyFailureValueKinds.Access, + Flags = Dev.PolicyFailureFlags.HasActionableDetails | Dev.PolicyFailureFlags.ResourceComplete, + RequestedValue = Dev.PolicyFailureAccessValues.ReadWrite, + RequiredValue = Dev.PolicyFailureAccessValues.ReadOnly, + }, + ], + }, + ], + }; + + var stored = failures.ToJson(); + var restored = Dev.PolicyFailures.FromJson(stored); + Assert.Equal(stored, restored.ToJson()); + Assert.Equal(2, restored.Results.Length); + Assert.Equal((uint)99, restored.Results[0].Version); + Assert.Equal((uint)98, restored.Results[0].MinimumReaderVersion); + Assert.Equal(uint.MaxValue, restored.Results[0].Outcome); + var first = restored.Results[0].Details[0]; + Assert.Equal(ulong.MaxValue, first.RequestedValue); + Assert.Equal(9_007_199_254_740_993UL, first.RequiredValue); + Assert.Equal(uint.MaxValue, first.Flags); + Assert.Equal(malformedResource, first.Resource); + var decoding = Assert.Throws(() => first.GetResourceText()); + Assert.Equal("policy resource is not valid UTF-16", decoding.Message); + Assert.Null(decoding.InnerException); + Assert.Equal(string.Empty, restored.Results[0].Details[1].GetResourceText()); + Assert.Null(restored.Results[0].Details[2].GetResourceText()); + Assert.Equal("\uD83D\uDE00", restored.Results[0].Details[3].GetResourceText()); + using var document = JsonDocument.Parse(stored); + var known = document.RootElement.GetProperty("results")[1]; + Assert.Equal(1u, known.GetProperty("outcome").GetUInt32()); + Assert.True(JsonNode.DeepEquals( + JsonNode.Parse(""" + {"failureClass":2,"failureReason":2,"requiredAction":2,"resourceKind":1, + "valueKind":1,"flags":3,"requestedValue":"2","requiredValue":"1"} + """), + JsonNode.Parse(known.GetProperty("details")[0].GetRawText()))); + var details = document.RootElement.GetProperty("results")[0].GetProperty("details"); + Assert.Equal("18446744073709551615", details[0].GetProperty("requestedValue").GetString()); + Assert.Equal("9007199254740993", details[0].GetProperty("requiredValue").GetString()); + Assert.Equal(0, details[1].GetProperty("resource").GetArrayLength()); + Assert.False(details[2].TryGetProperty("resource", out _)); + Assert.Equal(new ushort[] { 0xD800, 0, 0xDC00 }, malformedResource); + Assert.Equal(stored, failures.ToJson()); + var nullable = JsonNode.Parse(stored)!; + nullable["results"]![0]!["details"]![2]!["resource"] = null; + var normalized = Dev.PolicyFailures.FromJson(nullable.ToJsonString()); + Assert.Null(normalized.Results[0].Details[2].Resource); + Assert.Equal(stored, normalized.ToJson()); + } + + [Fact] + public void PolicyFailureStorageRejectsInvalidShapeVersionAndLossyValues() + { + foreach (var json in new[] + { + "null", "[]", "{}", + """{"formatVersion":2,"results":[]}""", + """{"formatVersion":1,"results":null}""", + """{"formatVersion":1,"results":[null]}""", + """{"formatVersion":1,"results":[],"extra":true}""", + """{"formatVersion":1,"formatVersion":1,"results":[]}""", + """{"formatVersion":1,"results":[{"version":1,"minimumReaderVersion":1,"outcome":1,"details":null}]}""", + }) + { + Assert.Throws(() => Dev.PolicyFailures.FromJson(json)); + } + + var valid = new Dev.PolicyFailures + { + Results = [new() { Details = [new() { RequestedValue = ulong.MaxValue }] }], + }.ToJson(); + foreach (var invalid in new[] { "2", "\"18446744073709551616\"", "\"-1\"", "\"+1\"", "\"1\\u0000\"" }) + { + var json = JsonNode.Parse(valid)!; + json["results"]![0]!["details"]![0]!["requestedValue"] = JsonNode.Parse(invalid); + Assert.Throws(() => Dev.PolicyFailures.FromJson(json.ToJsonString())); + } + foreach (var invalid in new[] { """{"extra":true}""", """{"resource":[65536]}""", """{"flags":-1}""" }) + { + var json = JsonNode.Parse(valid)!; + foreach (var property in JsonNode.Parse(invalid)!.AsObject()) + { + json["results"]![0]!["details"]![0]![property.Key] = property.Value?.DeepClone(); + } + Assert.Throws(() => Dev.PolicyFailures.FromJson(json.ToJsonString())); + } + } + + [Fact] + public void MalformedPolicyFailuresDoNotLeakInputIntoValidationErrors() + { + const string marker = "MXC_MALFORMED_POLICY_STRUCTURED_ONLY_9D133E1B"; + var unknown = $$$"""{"formatVersion":1,"results":[],"{{{marker}}}":"private resource"}"""; + var duplicate = $$$"""{"formatVersion":1,"results":[],"{{{marker}}}":true,"{{{marker}}}":false}"""; + foreach (var json in new[] { unknown, duplicate }) + { + AssertGenericValidationError(Assert.Throws( + () => Dev.PolicyFailures.FromJson(json))); + using var document = JsonDocument.Parse( + $$$"""{"policyFailures":{{{json}}},"backend":"preserved"}"""); + var original = new MxcException(ErrorCode.BackendError, "creation refused", + "PSEC.Create", "0x80070005", null, document.RootElement); + var extraction = Assert.Throws( + () => Dev.PolicyFailures.FromException(original)); + Assert.Equal(ErrorCode.BackendError, extraction.Code); + Assert.Equal("native policy failures are malformed", extraction.Message); + Assert.DoesNotContain(marker, extraction.ToString()); + Assert.Null(extraction.InnerException); + Assert.Null(extraction.Details); + Assert.Contains(marker, original.Details!.Value.GetRawText()); + Assert.Equal("preserved", original.Details.Value.GetProperty("backend").GetString()); + Assert.Equal("creation refused", original.Message); + Assert.Equal("PSEC.Create", original.Operation); + Assert.Equal("0x80070005", original.NativeCode); + } + AssertGenericValidationError(Assert.Throws( + () => Dev.PolicyFailures.FromJson(unknown[..^1]))); + + static void AssertGenericValidationError(JsonException error) + { + Assert.Equal("invalid stored policy failures", error.Message); + Assert.DoesNotContain(marker, error.Message); + Assert.DoesNotContain(marker, error.ToString()); + Assert.Null(error.InnerException); + } + } + + [Fact] + public async Task StoredTerminalPolicyFailuresReachNativePreparationLimitsWithoutMutatingInputs() + { + var terminal = new Dev.PolicyFailures + { + Results = Enumerable.Range(0, 65) + .Select(index => new Dev.PolicyFailureResult { Outcome = (uint)index }).ToArray(), + }; + var stored = terminal.ToJson(); + var failures = Dev.PolicyFailures.FromJson(stored); + const string request = + """{"version":"1.0.0","containment":"process","process":{"commandLine":"must-not-run","timeout":1000}}"""; + + var sync = Assert.Throws( + () => Dev.MxcContainer.PreparePolicyJson(request, failures)); + var asynchronous = await Assert.ThrowsAsync( + () => Dev.MxcContainer.PreparePolicyJsonAsync( + request, failures, cancellationToken: TestContext.Current.CancellationToken)); + Assert.All(new[] { sync, asynchronous }, error => + { + Assert.Equal(ErrorCode.MalformedRequest, error.Code); + Assert.Contains("result limit", error.Message); + }); + Assert.Equal(stored, failures.ToJson()); + Assert.Equal(stored, terminal.ToJson()); + + var tooMany = new Dev.PolicyFailures + { + Results = Enumerable.Repeat(new Dev.PolicyFailureResult(), 66).ToArray(), + }; + Assert.Throws(() => tooMany.ToJson()); + var tooManyDetails = new Dev.PolicyFailures + { + Results = [new() { Details = Enumerable.Repeat(new Dev.PolicyFailureDetail(), 65).ToArray() }], + }; + Assert.Throws(() => tooManyDetails.ToJson()); + } + + [Fact] + public void TerminalPolicyFailureStorageKeepsTheCompleteExtraNativeBatch() + { + var results = Enumerable.Range(0, 17) + .Select(index => new Dev.PolicyFailureResult + { + Outcome = (uint)index, + Details = Enumerable.Range(0, 64) + .Select(detail => new Dev.PolicyFailureDetail + { + Resource = index < 9 && detail == 0 ? new ushort[32_767] : null, + }).ToArray(), + }).ToArray(); + var terminal = new Dev.PolicyFailures { Results = results }; + var restored = Dev.PolicyFailures.FromJson(terminal.ToJson()); + Assert.Equal(1088, restored.Results.Sum(result => result.Details.Length)); + Assert.Equal(294_912, restored.Results.Sum(result => + result.Details.Sum(detail => detail.Resource is null ? 0 : detail.Resource.Length + 1))); + + var tooManyDetails = new Dev.PolicyFailures + { + Results = [.. results, new() { Details = [new()] }], + }; + Assert.Throws(() => tooManyDetails.ToJson()); + var tooManyUnits = new Dev.PolicyFailures + { + Results = [ + .. Enumerable.Range(0, 9).Select(_ => new Dev.PolicyFailureResult + { + Details = [new() { Resource = new ushort[32_767] }], + }), + new() { Details = [new() { Resource = [] }] }, + ], + }; + Assert.Equal(10, tooManyUnits.Results.Sum(result => result.Details.Length)); + Assert.Throws(() => tooManyUnits.ToJson()); + Assert.Throws(() => new Dev.PolicyFailures + { + Results = [new() { Details = [new() { Resource = new ushort[32_768] }] }], + }.ToJson()); + Assert.Throws(() => Dev.PolicyFailures.FromJson( + new string(' ', 4 * 1024 * 1024 + 512 * 1024) + "{}")); + Assert.Equal(1088, terminal.Results.Sum(result => result.Details.Length)); + } + + [Fact] + public async Task PolicyPreparationReturnsOwnedNativeRequestsWithoutLaunching() + { + Assert.SkipUnless(HostCanPreparePolicy, "policy preparation requires a PSEC-capable host"); + const string request = + """{"version":"1.1.0-alpha","containment":"processcontainer","process":{"commandLine":"MXC_PREPARATION_MUST_NOT_EXECUTE_0A7C5DF1.exe","timeout":1000}}"""; + var failures = new Dev.PolicyFailures(); + var stored = failures.ToJson(); + var sync = Dev.MxcContainer.PreparePolicyJson(request, failures); + var asynchronous = await Dev.MxcContainer.PreparePolicyJsonAsync( + request, failures, cancellationToken: TestContext.Current.CancellationToken); + foreach (var prepared in new[] { sync, asynchronous }) + { + Assert.Empty(prepared.Changes); + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(request), JsonNode.Parse(prepared.RequestJson))); + Assert.Equal(prepared.RequestJson, + Dev.PreparedPolicyRequest.FromJson(prepared.ToJson()).RequestJson); + } + Assert.Equal(stored, failures.ToJson()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void PolicyPreparationStoragePreservesLargeTextWithoutHtmlExpansion(bool nativePreparation) + { + Assert.SkipUnless(!nativePreparation || HostCanPreparePolicy, + "policy preparation requires a PSEC-capable host"); + var request = + "{\"version\":\"1.1.0-alpha\",\"containment\":\"processcontainer\",\"_comment\":\"" + + new string('<', 6 * 1024 * 1024) + + "\",\"process\":{\"commandLine\":\"MXC_PREPARATION_MUST_NOT_EXECUTE_0A7C5DF1.exe\"}}"; + var nativeCompatible = "{\"requestJson\":\"" + + JsonEncodedText.Encode(request, JavaScriptEncoder.UnsafeRelaxedJsonEscaping) + + "\",\"changes\":[]}"; + var prepared = nativePreparation + ? Dev.MxcContainer.PreparePolicyJson(request, new()) + : Dev.PreparedPolicyRequest.FromJson(nativeCompatible); + Assert.Equal(request, prepared.RequestJson); + Assert.Empty(prepared.Changes); + var stored = prepared.ToJson(); + Assert.Equal(Encoding.UTF8.GetByteCount(nativeCompatible), Encoding.UTF8.GetByteCount(stored)); + Assert.Equal(request, Dev.PreparedPolicyRequest.FromJson(stored).RequestJson); + Assert.Contains("\\u003C", MxcJson.Serialize("<", MxcJson.Options)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void PolicyPreparationStoragePreservesDuplicateCommentMembers(bool nativePreparation) + { + Assert.SkipUnless(!nativePreparation || HostCanPreparePolicy, + "policy preparation requires a PSEC-capable host"); + const string request = + """ {"version":"1.1.0-alpha","containment":"processcontainer","_comment":{"x":1,"x":2},"process":{"commandLine":"MXC_PREPARATION_MUST_NOT_EXECUTE_0A7C5DF1.exe"}} """; + var nativeCompatible = "{\"requestJson\":\"" + + JsonEncodedText.Encode(request, JavaScriptEncoder.UnsafeRelaxedJsonEscaping) + + "\",\"changes\":[]}"; + var prepared = nativePreparation + ? Dev.MxcContainer.PreparePolicyJson(request, new()) + : Dev.PreparedPolicyRequest.FromJson(nativeCompatible); + Assert.Equal(request, prepared.RequestJson); + Assert.Empty(prepared.Changes); + Assert.Equal(request, Dev.PreparedPolicyRequest.FromJson(prepared.ToJson()).RequestJson); + } + + [Fact] + public void PolicyFailureMergeDeduplicatesWholeBatchesAndOwnsEveryResource() + { + var firstDetail = new Dev.PolicyFailureDetail + { + FailureClass = uint.MaxValue, + RequestedValue = ulong.MaxValue, + Resource = [0xD800, 0], + }; + var secondDetail = new Dev.PolicyFailureDetail { Resource = [] }; + var first = new Dev.PolicyFailures + { + Results = + [ + new() { Outcome = uint.MaxValue, Details = [firstDetail, secondDetail] }, + new() { Outcome = 1, Details = [firstDetail] }, + ], + }; + var second = Dev.PolicyFailures.FromJson(first.ToJson()); + second = new Dev.PolicyFailures + { + Results = [.. second.Results, new() { Outcome = uint.MaxValue, Details = [secondDetail, firstDetail] }], + }; + var originalFirst = first.ToJson(); + var originalSecond = second.ToJson(); + var merged = first.MergedWith(second); + Assert.Equal(3, merged.Results.Length); + Assert.Equal(new[] { uint.MaxValue, 1u, uint.MaxValue }, + merged.Results.Select(result => result.Outcome)); + Assert.Equal(ulong.MaxValue, merged.Results[0].Details[0].RequestedValue); + Assert.Equal(new ushort[] { 0xD800, 0 }, merged.Results[0].Details[0].Resource); + Assert.Equal(2, merged.Results[2].Details.Length); + Assert.Empty(merged.Results[2].Details[0].Resource!); + merged.Results[0].Details[0].Resource![0] = 42; + merged.Results[1].Details[0] = new(); + merged.Results[2] = new(); + Assert.Equal(originalFirst, first.ToJson()); + Assert.Equal(originalSecond, second.ToJson()); + } + + [Fact] + public void PolicyFailureMergeRejectsInvalidInputsAndCompleteOverflowWithoutTruncation() + { + var first = new Dev.PolicyFailures + { + Results = Enumerable.Range(0, 65) + .Select(index => new Dev.PolicyFailureResult { Outcome = (uint)index }).ToArray(), + }; + var second = new Dev.PolicyFailures { Results = [new() { Outcome = 65 }] }; + var originalFirst = first.ToJson(); + var originalSecond = second.ToJson(); + Assert.Throws(() => first.MergedWith(second)); + Assert.Equal(originalFirst, first.ToJson()); + Assert.Equal(originalSecond, second.ToJson()); + var invalid = new Dev.PolicyFailures { FormatVersion = 2 }; + Assert.Throws(() => invalid.MergedWith(second)); + Assert.Throws(() => second.MergedWith(invalid)); + var invalidDuplicates = new Dev.PolicyFailures + { + Results = Enumerable.Repeat(new Dev.PolicyFailureResult(), 66).ToArray(), + }; + Assert.Throws(() => second.MergedWith(invalidDuplicates)); + Assert.Equal(originalSecond, second.ToJson()); + } + + [Fact] + public void PreparedPolicyStorageRetainsRequiredNullValuesAndOwnsJsonElements() + { + Dev.PreparedPolicyRequest prepared; + using (var document = JsonDocument.Parse( + """{"null":null,"old":{"nested":[true,18446744073709551615]},"new":"caf\u00e9 \ud83d\ude00"}""")) + { + var root = document.RootElement; + prepared = new Dev.PreparedPolicyRequest + { + RequestJson = """{"version":"1.1.0-alpha","process":{"commandLine":"echo"}}""", + Changes = + [ + new() + { + Operation = Dev.PolicyChangeOperation.Add, Path = "/a~1b/~0field", + Before = root.GetProperty("null"), After = root.GetProperty("old"), + }, + new() + { + Operation = Dev.PolicyChangeOperation.Remove, Path = "/old", + Before = root.GetProperty("old"), After = root.GetProperty("null"), + }, + new() + { + Operation = Dev.PolicyChangeOperation.Replace, Path = "/value", + Before = root.GetProperty("null"), After = root.GetProperty("new"), + }, + ], + }; + } + var stored = prepared.ToJson(); + var restored = Dev.PreparedPolicyRequest.FromJson(stored); + Assert.Equal(stored, restored.ToJson()); + Assert.Equal(JsonValueKind.Null, restored.Changes[0].Before.ValueKind); + Assert.Equal(JsonValueKind.Null, restored.Changes[1].After.ValueKind); + Assert.Equal(JsonValueKind.Null, restored.Changes[2].Before.ValueKind); + Assert.Equal("caf\u00e9 \ud83d\ude00", restored.Changes[2].After.GetString()); + Assert.Equal(ulong.MaxValue, + restored.Changes[0].After.GetProperty("nested")[1].GetUInt64()); + using var saved = JsonDocument.Parse(stored); + Assert.Equal(new[] { "add", "remove", "replace" }, + saved.RootElement.GetProperty("changes").EnumerateArray() + .Select(change => change.GetProperty("operation").GetString())); + Assert.All(saved.RootElement.GetProperty("changes").EnumerateArray(), change => + { + Assert.True(change.TryGetProperty("before", out _)); + Assert.True(change.TryGetProperty("after", out _)); + }); + restored.Changes[0] = new(); + Assert.Equal(stored, prepared.ToJson()); + } + + [Fact] + public void PreparedPolicyStorageRejectsMalformedShapesPointersAndOperationsWithoutInputLeaks() + { + const string marker = "MXC_PRIVATE_PREPARATION_INPUT_5F26AC94"; + const string valid = + """{"requestJson":"{}","changes":[{"operation":"add","path":"/x","before":null,"after":1}]}"""; + var invalid = new List + { + "null", "[]", "{}", """{"requestJson":null,"changes":[]}""", + """{"requestJson":"[]","changes":[]}""", """{"requestJson":"{}","changes":null}""", + """{"requestJson":"{}","changes":[null]}""", + $$$"""{"requestJson":"{}","changes":[],"{{{marker}}}":true}""", + valid.Replace("\"before\":null,", "", StringComparison.Ordinal), + valid.Replace(",\"after\":1", "", StringComparison.Ordinal), + valid.Replace("\"before\":null", "\"before\":true", StringComparison.Ordinal), + valid.Replace("\"add\"", "\"remove\"", StringComparison.Ordinal), + valid.Replace("\"add\"", "0", StringComparison.Ordinal), + valid.Replace("\"add\"", "\"Add\"", StringComparison.Ordinal), + valid.Replace("\"add\"", $"\"{marker}\"", StringComparison.Ordinal), + valid.Replace("\"/x\"", "\"x\"", StringComparison.Ordinal), + valid.Replace("\"/x\"", "\"/~2\"", StringComparison.Ordinal), + valid.Replace("\"/x\"", "\"/~\"", StringComparison.Ordinal), + valid.Replace("\"after\":1", $"\"after\":{{\"{marker}\":true,\"{marker}\":false}}", StringComparison.Ordinal), + valid[..^1], + }; + foreach (var json in invalid) + { + var error = Assert.Throws(() => Dev.PreparedPolicyRequest.FromJson(json)); + Assert.Equal("invalid prepared policy request", error.Message); + Assert.DoesNotContain(marker, error.ToString()); + Assert.Null(error.InnerException); + } + Assert.Throws(() => new Dev.PreparedPolicyRequest + { + RequestJson = "{}", + Changes = [new() { Operation = Dev.PolicyChangeOperation.Replace, Path = "/x" }], + }.ToJson()); + } + + [Fact] + public void PreparedPolicyStorageBoundsResultsAndChangesButAllowsExpandedRequests() + { + var nullValue = MxcJson.Deserialize("null"); + var change = new Dev.PolicyChange + { + Operation = Dev.PolicyChangeOperation.Replace, Path = "", + Before = nullValue, After = nullValue, + }; + var bounded = new Dev.PreparedPolicyRequest + { + RequestJson = "{}", + Changes = Enumerable.Repeat(change, 4096).ToArray(), + }; + Assert.Equal(4096, Dev.PreparedPolicyRequest.FromJson(bounded.ToJson()).Changes.Length); + Assert.Throws(() => new Dev.PreparedPolicyRequest + { + RequestJson = "{}", + Changes = [.. bounded.Changes, change], + }.ToJson()); + var expandedRequest = "{\"_comment\":\"" + new string('x', 8 * 1024 * 1024) + "\"}"; + var expanded = new Dev.PreparedPolicyRequest { RequestJson = expandedRequest }; + Assert.Equal(expandedRequest, Dev.PreparedPolicyRequest.FromJson(expanded.ToJson()).RequestJson); + Assert.Throws(() => new Dev.PreparedPolicyRequest + { + RequestJson = "{\"_comment\":\"" + new string('x', 32 * 1024 * 1024) + "\"}", + }.ToJson()); + Assert.Throws(() => Dev.PreparedPolicyRequest.FromJson( + new string(' ', 32 * 1024 * 1024) + """{"requestJson":"{}","changes":[]}""")); + } + + [Fact] + public unsafe void NativePreparedPolicyDataIsBoundedOwnedAndStrictUtf8() + { + const string valid = + """{"requestJson":"{}","changes":[{"operation":"replace","path":"/x","before":null,"after":{"large":18446744073709551615}}]}"""; + var bytes = Encoding.UTF8.GetBytes(valid + "\0"); + Dev.PreparedPolicyRequest prepared; + fixed (byte* pointer = bytes) + { + prepared = Dev.MxcContainer.ReadPreparedPolicyRequest(pointer); + } + Array.Fill(bytes, (byte)0); + Assert.Equal(ulong.MaxValue, prepared.Changes[0].After.GetProperty("large").GetUInt64()); + Assert.Equal(ErrorCode.BackendError, + Assert.Throws(() => Dev.MxcContainer.ReadPreparedPolicyRequest(null)).Code); + foreach (var malformed in new[] { new byte[] { 0xFF, 0 }, Encoding.UTF8.GetBytes("private input\0") }) + { + fixed (byte* pointer = malformed) + { + try + { + Dev.MxcContainer.ReadPreparedPolicyRequest(pointer); + Assert.Fail("malformed native preparation data was accepted"); + } + catch (MxcException error) + { + Assert.Equal(ErrorCode.BackendError, error.Code); + Assert.Equal("native policy preparation returned invalid JSON", error.Message); + Assert.DoesNotContain("private input", error.ToString()); + Assert.Null(error.InnerException); + } + } + } + var oversized = new byte[32 * 1024 * 1024 + 2]; + Array.Fill(oversized, (byte)' '); + oversized[^1] = 0; + fixed (byte* pointer = oversized) + { + try + { + Dev.MxcContainer.ReadPreparedPolicyRequest(pointer); + Assert.Fail("oversized native preparation data was accepted"); + } + catch (MxcException error) + { + Assert.Equal(ErrorCode.BackendError, error.Code); + Assert.Contains("byte limit", error.Message); + } + } + } + + [Fact] + public async Task PolicyPreparationRejectsInvalidInputsWithoutLeaksAndHonorsPreCancellation() + { + const string marker = "MXC_PRIVATE_PREPARATION_INPUT_5F26AC94"; + foreach (var json in new[] + { + "{\"" + marker + "\":", "{}\0" + marker, + "{\"_comment\":\"" + marker + "\ud800\"}", + "{\"_comment\":\"" + new string('\u00e9', 4 * 1024 * 1024) + "\"}", + }) + { + var error = Assert.Throws( + () => Dev.MxcContainer.PreparePolicyJson(json, new())); + Assert.Equal(ErrorCode.MalformedRequest, error.Code); + Assert.Equal("invalid MXC request JSON for policy preparation", error.Message); + Assert.DoesNotContain(marker, error.ToString()); + Assert.Null(error.InnerException); + } + var invalidFailures = Assert.Throws( + () => Dev.MxcContainer.PreparePolicyJson("{}", new() { FormatVersion = 2 })); + Assert.Equal(ErrorCode.MalformedRequest, invalidFailures.Code); + Assert.Null(invalidFailures.InnerException); + await Assert.ThrowsAnyAsync( + () => Dev.MxcContainer.PreparePolicyJsonAsync( + "{}", new(), cancellationToken: new CancellationToken(true))); } } diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcExceptionTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcExceptionTests.cs index a05eaddeaa..46c9cf8873 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcExceptionTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcExceptionTests.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Reflection; +using System.Text.Json; using Microsoft.Mxc.Sdk; using Microsoft.Mxc.Sdk.V1; using Xunit; @@ -67,6 +68,22 @@ public void WithoutApiDetail_TheCallFieldsAreNull() Assert.Null(ex.Operation); Assert.Null(ex.NativeCode); Assert.Null(ex.Remediation); + Assert.Null(ex.Details); + } + + [Fact] + public void BackendDetailsAreIndependentOfTheOriginalJsonDocument() + { + MxcException exception; + using (var document = JsonDocument.Parse("""{"backend":{"observations":[1,true,"value"]}}""")) + { + exception = new MxcException(ErrorCode.BackendError, "unchanged", + "PSEC.Create", "0x80070005", null, document.RootElement); + } + + Assert.Equal("value", exception.Details!.Value.GetProperty("backend").GetProperty("observations")[2].GetString()); + Assert.Equal("unchanged", exception.Message); + Assert.Equal("0x80070005", exception.NativeCode); } [Fact] diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcProcessTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcProcessTests.cs index b13eaa613b..cc6045f292 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcProcessTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcProcessTests.cs @@ -2,6 +2,8 @@ // Licensed under the MIT License. using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; using Microsoft.Mxc.Sdk; using Microsoft.Mxc.Sdk.V1; using Dev = Microsoft.Mxc.Sdk.V1.Dev; @@ -19,16 +21,25 @@ public class MxcProcessTests Environment.GetEnvironmentVariable("MXC_E2E_PSEC_CAPABLE_HOST") == "1"; [Theory] - [InlineData("1.0.0")] - [InlineData("1.1.0-alpha")] - public async Task DevJson_CapturesOutputAndStreamsInput(string version) + [InlineData("1.0.0", false)] + [InlineData("1.1.0-alpha", false)] + [InlineData("1.1.0-alpha", true)] + public async Task DevJson_CapturesOutputAndStreamsInput(string version, bool preparePolicy) { Assert.SkipUnless(HostCanSpawn, "no host backend available"); + Assert.SkipUnless(!preparePolicy || OperatingSystem.IsWindows(), + "policy preparation requires Windows ProcessContainer"); + if (preparePolicy) + { + await RunPreparedFilesystemScenarioAsync(version); + return; + } + var options = new Dev.JsonOptions(); var creation = OperatingSystem.IsWindows() ? $$$"""{"version":"{{{version}}}","containment":"process","process":{"commandLine":"cmd.exe /c \"echo DEV_CAPTURE_OK & echo DEV_CAPTURE_ERR 1>&2 & exit /b 7\"","timeout":30000}}""" : $$$"""{"version":"{{{version}}}","containment":"process","process":{"commandLine":"printf 'DEV_CAPTURE_OK\\n'; printf 'DEV_CAPTURE_ERR\\n' >&2; exit 7","timeout":30000}}"""; var result = await Dev.MxcContainer.RunJsonAsync( - creation, cancellationToken: TestContext.Current.CancellationToken); + creation, options, TestContext.Current.CancellationToken); Assert.False(result.TimedOut); Assert.Equal(7, result.ExitCode); Assert.Contains("DEV_CAPTURE_OK", result.Stdout); @@ -52,6 +63,153 @@ await stdin.WriteAsync(Encoding.UTF8.GetBytes("ping-pong\r\n"), Assert.Contains("DEV_PIPE:ping-pong", Encoding.UTF8.GetString(stdout)); } + private static async Task RunPreparedFilesystemScenarioAsync(string version) + { + var fixture = Path.Combine(AppContext.BaseDirectory, $"policy-preparation-{Guid.NewGuid():N}"); + var restricted = Path.Combine(fixture, "restricted"); + var permitted = Path.Combine(fixture, "permitted"); + try + { + Directory.CreateDirectory(restricted); + Directory.CreateDirectory(permitted); + var seed = Path.Combine(restricted, "seed.txt"); + var capturedSentinel = Path.Combine(permitted, "captured.txt"); + var pipedSentinel = Path.Combine(permitted, "piped.txt"); + File.WriteAllText(seed, "seed"); + File.WriteAllText(Path.Combine(restricted, "relative.txt"), "ORIGINAL_CWD"); + File.WriteAllText(Path.Combine(permitted, "relative.txt"), "WRONG_CWD"); + var failures = Dev.PolicyFailures.FromJson(new Dev.PolicyFailures + { + Results = + [ + new() + { + Outcome = Dev.PolicyFailureOutcomes.Blocked, + Details = + [ + new() + { + FailureClass = Dev.PolicyFailureClasses.Filesystem, + FailureReason = Dev.PolicyFailureReasons.AccessExceedsCeiling, + RequiredAction = Dev.PolicyFailureActions.RestrictFilesystemAccess, + ResourceKind = Dev.PolicyFailureResourceKinds.Path, + ValueKind = Dev.PolicyFailureValueKinds.Access, + Flags = Dev.PolicyFailureFlags.HasActionableDetails | Dev.PolicyFailureFlags.ResourceComplete, + RequestedValue = Dev.PolicyFailureAccessValues.ReadWrite, + RequiredValue = Dev.PolicyFailureAccessValues.ReadOnly, + Resource = restricted.Select(character => (ushort)character).ToArray(), + }, + ], + }, + ], + }.ToJson()); + var storedFailures = failures.ToJson(); + JsonObject Request(string command) => new() + { + ["version"] = version, + ["containerId"] = $"dotnet-{Path.GetFileName(fixture)}", + ["containment"] = "processcontainer", + ["filesystem"] = new JsonObject { ["readwritePaths"] = new JsonArray(restricted, permitted) }, + ["process"] = new JsonObject { ["commandLine"] = command, ["timeout"] = 30_000 }, + }; + string Accept(Dev.PreparedPolicyRequest prepared, JsonObject original) + { + var expected = (JsonObject)original.DeepClone(); + expected["filesystem"]!["readwritePaths"] = new JsonArray(permitted); + expected["filesystem"]!["readonlyPaths"] = new JsonArray(restricted); + expected["process"]!["cwd"] = restricted; + Assert.True(JsonNode.DeepEquals(expected, JsonNode.Parse(prepared.RequestJson))); + Assert.Equal(3, prepared.Changes.Length); + var changes = prepared.Changes.ToDictionary(change => change.Path, StringComparer.Ordinal); + var readwrite = changes["/filesystem/readwritePaths"]; + Assert.Equal(Dev.PolicyChangeOperation.Replace, readwrite.Operation); + Assert.True(JsonNode.DeepEquals(new JsonArray(restricted, permitted), + JsonNode.Parse(readwrite.Before.GetRawText()))); + Assert.True(JsonNode.DeepEquals(new JsonArray(permitted), + JsonNode.Parse(readwrite.After.GetRawText()))); + var readOnly = changes["/filesystem/readonlyPaths"]; + Assert.Equal(Dev.PolicyChangeOperation.Add, readOnly.Operation); + Assert.Equal(JsonValueKind.Null, readOnly.Before.ValueKind); + Assert.True(JsonNode.DeepEquals(new JsonArray(restricted), + JsonNode.Parse(readOnly.After.GetRawText()))); + var cwd = changes["/process/cwd"]; + Assert.Equal(Dev.PolicyChangeOperation.Add, cwd.Operation); + Assert.Equal(JsonValueKind.Null, cwd.Before.ValueKind); + Assert.Equal(restricted, cwd.After.GetString()); + TestContext.Current.TestOutputHelper?.WriteLine($"MXC_PREPARED_FILESYSTEM_ACCEPTED:{prepared.ToJson()}"); + return prepared.RequestJson; + } + + var creation = Request( + $"cmd.exe /d /c echo CHANGED>>\"{seed}\" & echo CONTROL>>\"{capturedSentinel}\"" + + " & type relative.txt & echo DEV_CAPTURE_OK & echo DEV_CAPTURE_ERR 1>&2 & exit /b 7"); + var originalJson = creation.ToJsonString(); + var baseline = await Dev.MxcContainer.RunJsonAsync( + originalJson, cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(7, baseline.ExitCode); + Assert.Contains("CHANGED", File.ReadAllText(seed)); + Assert.Contains("CONTROL", File.ReadAllText(capturedSentinel)); + TestContext.Current.TestOutputHelper?.WriteLine("MXC_PREPARED_FILESYSTEM_BASELINE_RW_OK"); + File.WriteAllText(seed, "seed"); + File.Delete(capturedSentinel); + + var prepared = await Dev.MxcContainer.PreparePolicyJsonAsync( + originalJson, failures, cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal("seed", File.ReadAllText(seed)); + Assert.False(File.Exists(capturedSentinel)); + Assert.Equal(originalJson, creation.ToJsonString()); + var accepted = Accept(prepared, creation); + var result = await Dev.MxcContainer.RunJsonAsync( + accepted, cancellationToken: TestContext.Current.CancellationToken); + Assert.False(result.TimedOut); + Assert.Equal(7, result.ExitCode); + Assert.Contains("DEV_CAPTURE_OK", result.Stdout); + Assert.Contains("ORIGINAL_CWD", result.Stdout); + Assert.DoesNotContain("WRONG_CWD", result.Stdout); + Assert.Contains("DEV_CAPTURE_ERR", result.Stderr); + Assert.Empty(result.Warnings); + Assert.Equal("seed", File.ReadAllText(seed)); + Assert.Contains("CONTROL", File.ReadAllText(capturedSentinel)); + + var piped = Request( + $"cmd.exe /d /v:on /c set /p L= & echo CHANGED>>\"{seed}\" & echo PIPE_CONTROL>>\"{pipedSentinel}\"" + + " & type relative.txt & echo DEV_PIPE:!L! & echo DEV_PIPE_ERR 1>&2 & exit /b 9"); + var originalPipedJson = piped.ToJsonString(); + prepared = Dev.MxcContainer.PreparePolicyJson(originalPipedJson, failures); + Assert.False(File.Exists(pipedSentinel)); + Assert.Equal(originalPipedJson, piped.ToJsonString()); + accepted = Accept(prepared, piped); + using var process = await Dev.MxcContainer.SpawnJsonAsync( + accepted, cancellationToken: TestContext.Current.CancellationToken); + using var stdin = process.StandardInput; + Assert.NotNull(stdin); + var output = process.WaitForExitWithOutputAsync(TestContext.Current.CancellationToken); + await stdin.WriteAsync(Encoding.UTF8.GetBytes("ping-pong\r\n"), + TestContext.Current.CancellationToken); + stdin.Dispose(); + var (outcome, stdout, stderr) = await output; + Assert.False(outcome.TimedOut); + Assert.Equal(9, outcome.ExitCode); + Assert.Contains("DEV_PIPE:ping-pong", Encoding.UTF8.GetString(stdout)); + Assert.Contains("ORIGINAL_CWD", Encoding.UTF8.GetString(stdout)); + Assert.DoesNotContain("WRONG_CWD", Encoding.UTF8.GetString(stdout)); + Assert.Contains("DEV_PIPE_ERR", Encoding.UTF8.GetString(stderr)); + Assert.Empty(process.Warnings); + Assert.Equal("seed", File.ReadAllText(seed)); + Assert.Contains("PIPE_CONTROL", File.ReadAllText(pipedSentinel)); + Assert.Equal(storedFailures, failures.ToJson()); + TestContext.Current.TestOutputHelper?.WriteLine( + $"MXC_PREPARED_FILESYSTEM_STRICT_RUN_SPAWN_OK:{version}:seed=unchanged:permitted=written:cwd=ORIGINAL_CWD"); + } + finally + { + if (Directory.Exists(fixture)) + { + Directory.Delete(fixture, recursive: true); + } + } + } + [Theory] [InlineData(null, null)] [InlineData(0u, null)] diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/NativeErrorTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/NativeErrorTests.cs index b56e9fb614..f12bc1e315 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/NativeErrorTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/NativeErrorTests.cs @@ -3,9 +3,11 @@ using System; using System.Runtime.InteropServices; +using System.Text.Json; using Microsoft.Mxc.Sdk; using Microsoft.Mxc.Sdk.V1; using Microsoft.Mxc.Sdk.Native; +using Dev = Microsoft.Mxc.Sdk.V1.Dev; using Xunit; namespace Microsoft.Mxc.Sdk.Tests; @@ -38,13 +40,15 @@ private static MxcErrorDetail Detail( string? message, string? operation = null, string? nativeCode = null, - string? remediation = null) => + string? remediation = null, + string? detailsJson = null) => new() { message_utf8 = Utf8(message), operation_utf8 = Utf8(operation), native_code_utf8 = Utf8(nativeCode), remediation_utf8 = Utf8(remediation), + details_json_utf8 = Utf8(detailsJson), }; /// @@ -64,6 +68,7 @@ private static void Free(MxcErrorDetail detail) Marshal.FreeCoTaskMem((IntPtr)detail.operation_utf8); Marshal.FreeCoTaskMem((IntPtr)detail.native_code_utf8); Marshal.FreeCoTaskMem((IntPtr)detail.remediation_utf8); + Marshal.FreeCoTaskMem((IntPtr)detail.details_json_utf8); } [Fact] @@ -85,6 +90,7 @@ public void EachNativeFieldLandsOnItsOwnProperty() Assert.Equal("IsoSessionOps.StopSessionAsync", ex.Operation); Assert.Equal("0x80070490", ex.NativeCode); Assert.Equal("Provision the session first.", ex.Remediation); + Assert.Null(ex.Details); } finally { @@ -156,4 +162,160 @@ public void TheStatusCodeCrossesAsTheErrorCode() Free(detail); } } + + [Fact] + public void MergedPolicyFailuresAndOtherDetailsOutliveNativeMemory() + { + const string resourceMarker = "MXC_POLICY_RESOURCE_STRUCTURED_ONLY_7B30AC71"; + const string capabilityMarker = "MXC_POLICY_CAPABILITY_STRUCTURED_ONLY_2AE56531"; + var failures = new Dev.PolicyFailures + { + Results = + [ + new() + { + Outcome = Dev.PolicyFailureOutcomes.Blocked, + Details = + [ + new() + { + RequestedValue = ulong.MaxValue, + RequiredValue = 9_007_199_254_740_993, + Resource = resourceMarker.Select(static character => (ushort)character).ToArray(), + }, + ], + }, + new() + { + Version = 99, + MinimumReaderVersion = 98, + Outcome = uint.MaxValue, + Details = [new() { FailureClass = uint.MaxValue, Resource = [0xD800, 0] }], + }, + ], + }; + var detailsJson = $$$""" + {"policyFailures":{{{failures.ToJson()}}},"policyFeedbackError":{"code":"feedbackLimitExceeded","reason":"result limit"}, + "policyResultUnavailable":"no new result", + "other":{"native":[true,18446744073709551615],"capability":"{{{capabilityMarker}}}"}} + """; + var detail = Detail("creation refused", "CreateProcessSecurityEnvironment", + Dev.PolicyFailureNativeCodes.PolicyBlocked, "check policy", detailsJson); + var warnings = Utf8("""["ordinary native warning"]"""); + MxcException exception; + ExecutionResult success; + try + { + var result = new MxcRunResult + { + error = detail, + exit_code = 7, + warnings_json_utf8 = warnings, + }; + exception = Assert.Throws( + () => Dev.MxcContainer.ReadRunResult((int)ErrorCode.BackendError, result)); + // Even a success-shaped result must not turn its error details into warnings. + success = Dev.MxcContainer.ReadRunResult((int)ErrorCode.Success, result); + } + finally + { + Free(detail); + Marshal.FreeCoTaskMem((IntPtr)warnings); + } + + Assert.Equal(ErrorCode.BackendError, exception.Code); + Assert.Equal("creation refused", exception.Message); + Assert.Equal("CreateProcessSecurityEnvironment", exception.Operation); + Assert.Equal("0x800704EC", exception.NativeCode); + Assert.Equal("check policy", exception.Remediation); + Assert.DoesNotContain("policyFailures", exception.ToString()); + Assert.NotNull(exception.StackTrace); + Assert.Equal(7, success.ExitCode); + Assert.Equal(new[] { "ordinary native warning" }, success.Warnings); + var rendered = new[] { exception.Message, exception.ToString(), exception.StackTrace! } + .Concat(success.Warnings); + foreach (var text in rendered) + { + Assert.DoesNotContain(resourceMarker, text); + Assert.DoesNotContain(capabilityMarker, text); + Assert.DoesNotContain("18446744073709551615", text); + Assert.DoesNotContain("9007199254740993", text); + Assert.DoesNotContain("feedbackLimitExceeded", text); + Assert.DoesNotContain("result limit", text); + Assert.DoesNotContain(failures.ToJson(), text); + } + var details = exception.Details!.Value; + var terminal = details.GetProperty("policyFeedbackError"); + Assert.Equal("feedbackLimitExceeded", terminal.GetProperty("code").GetString()); + Assert.Equal("result limit", terminal.GetProperty("reason").GetString()); + Assert.Equal("no new result", details.GetProperty("policyResultUnavailable").GetString()); + Assert.Equal(ulong.MaxValue, details.GetProperty("other").GetProperty("native")[1].GetUInt64()); + Assert.Equal(capabilityMarker, details.GetProperty("other").GetProperty("capability").GetString()); + var extracted = Dev.PolicyFailures.FromException(exception); + Assert.NotNull(extracted); + Assert.Equal(2, extracted.Results.Length); + Assert.Equal(resourceMarker, extracted.Results[0].Details[0].GetResourceText()); + Assert.Equal(uint.MaxValue, extracted.Results[1].Outcome); + Assert.Equal(new ushort[] { 0xD800, 0 }, extracted.Results[1].Details[0].Resource); + Assert.Equal(failures.ToJson(), Dev.PolicyFailures.FromJson(extracted.ToJson()).ToJson()); + Assert.Equal(resourceMarker, + Dev.PolicyFailures.FromJson(extracted.ToJson()).Results[0].Details[0].GetResourceText()); + } + + [Fact] + public void MalformedOrNonObjectNativeDetailsAreBackendErrors() + { + foreach (var json in new[] { "{", "[]", "null", "\"text\"" }) + { + var detail = Detail("original error", detailsJson: json); + try + { + var exception = NativeError.ToException( + (int)ErrorCode.PolicyValidation, detail, "unused"); + Assert.Equal(ErrorCode.BackendError, exception.Code); + Assert.Equal("native runtime returned malformed error details", exception.Message); + Assert.Equal( + (int)ErrorCode.PolicyValidation, + exception.Details!.Value.GetProperty("ffiStatus").GetInt32()); + } + finally + { + Free(detail); + } + } + } + + [Fact] + public void PolicyFailureExtractionDistinguishesAbsenceFromMalformedObservations() + { + Assert.Null(Dev.PolicyFailures.FromException(new MxcException(ErrorCode.BackendError, "ordinary error"))); + foreach (var json in new[] + { + """{"other":"retained"}""", + """{"other":"retained","policyFailures":null}""", + """{"other":"retained","policyFailures":{"formatVersion":2,"results":[]}}""", + }) + { + var detail = Detail("original error", detailsJson: json); + try + { + var exception = NativeError.ToException((int)ErrorCode.BackendError, detail, "unused"); + if (json.Contains("policyFailures", StringComparison.Ordinal)) + { + var extraction = Assert.Throws(() => Dev.PolicyFailures.FromException(exception)); + Assert.Equal(ErrorCode.BackendError, extraction.Code); + } + else + { + Assert.Null(Dev.PolicyFailures.FromException(exception)); + } + Assert.Equal("original error", exception.Message); + Assert.Equal("retained", exception.Details!.Value.GetProperty("other").GetString()); + } + finally + { + Free(detail); + } + } + } } diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/MxcContainerTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/MxcContainerTests.cs index 8ab94c5fae..0dbf199db8 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/MxcContainerTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/MxcContainerTests.cs @@ -166,6 +166,7 @@ public void Probe_MapsStructuredNativeFailure(ErrorCode code) ErrorOperation = "ProcessModel.Probe", ErrorNativeCode = "0x80070005", ErrorRemediation = "check policy", + ErrorDetailsJson = """{"backend":{"retained":true},"policyFailures":{"formatVersion":1,"results":[]}}""", }; var previous = MxcContainer.RequestProbeInterop; MxcContainer.RequestProbeInterop = native; @@ -179,6 +180,8 @@ public void Probe_MapsStructuredNativeFailure(ErrorCode code) Assert.Equal("ProcessModel.Probe", error.Operation); Assert.Equal("0x80070005", error.NativeCode); Assert.Equal("check policy", error.Remediation); + Assert.True(error.Details!.Value.GetProperty("backend").GetProperty("retained").GetBoolean()); + Assert.Empty(Microsoft.Mxc.Sdk.V1.Dev.PolicyFailures.FromException(error)!.Results); Assert.True(native.ErrorFreed); } finally @@ -657,6 +660,7 @@ private sealed unsafe class FakeRequestProbeInterop : IRequestProbeInterop, IDis internal string? ErrorOperation { get; init; } internal string? ErrorNativeCode { get; init; } internal string? ErrorRemediation { get; init; } + internal string? ErrorDetailsJson { get; init; } internal string? RequestJson { get; private set; } internal bool OutputFreed { get; private set; } internal bool ErrorFreed { get; private set; } @@ -679,6 +683,7 @@ public int Probe( error->operation_utf8 = Allocate(ErrorOperation); error->native_code_utf8 = Allocate(ErrorNativeCode); error->remediation_utf8 = Allocate(ErrorRemediation); + error->details_json_utf8 = Allocate(ErrorDetailsJson); return Status; } @@ -694,6 +699,7 @@ public void FreeError(MxcErrorDetail* error) Free(error->operation_utf8); Free(error->native_code_utf8); Free(error->remediation_utf8); + Free(error->details_json_utf8); *error = default; ErrorFreed = true; } diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/V1ApiSurfaceTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/V1ApiSurfaceTests.cs index c49f00e8a9..88f941f7eb 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/V1ApiSurfaceTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/V1ApiSurfaceTests.cs @@ -30,8 +30,24 @@ public void PublicSurfaceUsesContainerRequestWithoutAggregatePolicyTypes() { typeof(Dev.JsonOptions), typeof(Dev.PtyJsonOptions), + typeof(Dev.PolicyPreparationOptions), + typeof(Dev.PreparedPolicyRequest), + typeof(Dev.PolicyChange), + typeof(Dev.PolicyChangeOperation), typeof(Dev.MxcContainer), typeof(Dev.MxcLifecycle), + typeof(Dev.PolicyFailures), + typeof(Dev.PolicyFailureResult), + typeof(Dev.PolicyFailureDetail), + typeof(Dev.PolicyFailureNativeCodes), + typeof(Dev.PolicyFailureOutcomes), + typeof(Dev.PolicyFailureClasses), + typeof(Dev.PolicyFailureReasons), + typeof(Dev.PolicyFailureActions), + typeof(Dev.PolicyFailureResourceKinds), + typeof(Dev.PolicyFailureValueKinds), + typeof(Dev.PolicyFailureAccessValues), + typeof(Dev.PolicyFailureFlags), }; Assert.All( exportedTypes, @@ -77,4 +93,35 @@ public void StableOperationOptionsDoNotExposeExperimentalAuthorization() Assert.All(optionTypes, type => Assert.Null(type.GetProperty("Experimental"))); } + + [Fact] + public void DevPreparationIsSeparateFromCreationOptionsAndKeepsCancellationLast() + { + Assert.Equal(new[] { "Experimental" }, + typeof(Dev.JsonOptions).GetProperties().Select(property => property.Name)); + Assert.Equal(new[] { "Experimental" }, + typeof(Dev.PolicyPreparationOptions).GetProperties().Select(property => property.Name)); + Assert.All(new[] + { + typeof(Dev.JsonOptions), typeof(Dev.PolicyPreparationOptions), + typeof(Dev.PolicyChange), typeof(Dev.PreparedPolicyRequest), + }, type => Assert.True(type.IsSealed)); + Assert.Equal(typeof(System.Text.Json.JsonElement), + typeof(Dev.PolicyChange).GetProperty(nameof(Dev.PolicyChange.Before))!.PropertyType); + Assert.Equal(typeof(System.Text.Json.JsonElement), + typeof(Dev.PolicyChange).GetProperty(nameof(Dev.PolicyChange.After))!.PropertyType); + + var sync = typeof(Dev.MxcContainer).GetMethod(nameof(Dev.MxcContainer.PreparePolicyJson))!; + Assert.Equal(typeof(Dev.PreparedPolicyRequest), sync.ReturnType); + Assert.Equal(new[] { typeof(string), typeof(Dev.PolicyFailures), typeof(Dev.PolicyPreparationOptions) }, + sync.GetParameters().Select(parameter => parameter.ParameterType)); + Assert.True(sync.GetParameters()[^1].IsOptional); + var async = typeof(Dev.MxcContainer).GetMethod(nameof(Dev.MxcContainer.PreparePolicyJsonAsync))!; + Assert.Equal(typeof(Task), async.ReturnType); + Assert.Equal(new[] + { + typeof(string), typeof(Dev.PolicyFailures), typeof(Dev.PolicyPreparationOptions), typeof(CancellationToken), + }, async.GetParameters().Select(parameter => parameter.ParameterType)); + Assert.True(async.GetParameters()[^1].IsOptional); + } } diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcJsonSerialization.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcJsonSerialization.cs index a25972153a..166fdced20 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcJsonSerialization.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcJsonSerialization.cs @@ -48,6 +48,13 @@ public CamelCaseJsonStringEnumConverter() [JsonSerializable(typeof(SpawnWithPtyOptions))] [JsonSerializable(typeof(JsonOptions))] [JsonSerializable(typeof(PtyJsonOptions))] +[JsonSerializable(typeof(PolicyPreparationOptions))] +[JsonSerializable(typeof(PreparedPolicyRequest))] +[JsonSerializable(typeof(PolicyChange))] +[JsonSerializable(typeof(PolicyChangeOperation))] +[JsonSerializable(typeof(PolicyFailures))] +[JsonSerializable(typeof(PolicyFailureResult))] +[JsonSerializable(typeof(PolicyFailureDetail))] [JsonSerializable(typeof(ProvisionOptions))] [JsonSerializable(typeof(StartOptions))] [JsonSerializable(typeof(StopOptions))] @@ -101,6 +108,7 @@ public CamelCaseJsonStringEnumConverter() [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(bool))] [JsonSerializable(typeof(bool?))] +[JsonSerializable(typeof(JsonElement))] internal sealed partial class MxcJsonContext : JsonSerializerContext { } @@ -195,4 +203,27 @@ internal static string Serialize(T value, JsonSerializerOptions options) => JsonSerializer.Deserialize(json, TypeInfo(options)); internal static T? Deserialize(string json) => Deserialize(json, Options); + + internal static void RejectDuplicateProperties(JsonElement element) + { + if (element.ValueKind == JsonValueKind.Object) + { + var names = new HashSet(StringComparer.Ordinal); + foreach (var property in element.EnumerateObject()) + { + if (!names.Add(property.Name)) + { + throw new JsonException("duplicate JSON property"); + } + RejectDuplicateProperties(property.Value); + } + } + else if (element.ValueKind == JsonValueKind.Array) + { + foreach (var item in element.EnumerateArray()) + { + RejectDuplicateProperties(item); + } + } + } } diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/Native/NativeError.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/Native/NativeError.cs index bf202d7ab1..502aa1187d 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/Native/NativeError.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/Native/NativeError.cs @@ -2,6 +2,8 @@ // Licensed under the MIT License. using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Nodes; using Microsoft.Mxc.Sdk.V1; @@ -33,12 +35,45 @@ internal static MxcException ToException( MxcErrorDetail detail, string fallbackMessage) { + var detailsJson = ToStringOrNull(detail.details_json_utf8); + JsonElement? details = null; + if (detailsJson is not null) + { + var cause = TryParseDetails(detailsJson, out var parsed); + if (cause is not null) + { + var diagnostic = new JsonObject { ["ffiStatus"] = status, ["cause"] = cause }; + return new MxcException( + ErrorCode.BackendError, + "native runtime returned malformed error details", + null, + null, + null, + MxcJson.Deserialize(diagnostic.ToJsonString())); + } + details = parsed; + } return new MxcException( (ErrorCode)status, ToStringOrNull(detail.message_utf8) ?? fallbackMessage, ToStringOrNull(detail.operation_utf8), ToStringOrNull(detail.native_code_utf8), - ToStringOrNull(detail.remediation_utf8)); + ToStringOrNull(detail.remediation_utf8), + details); + } + + private static string? TryParseDetails(string detailsJson, out JsonElement details) + { + try + { + details = MxcJson.Deserialize(detailsJson); + } + catch (JsonException ex) + { + details = default; + return ex.Message; + } + return details.ValueKind == JsonValueKind.Object ? null : "expected a JSON object"; } /// diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/PolicyFailureValueJsonConverter.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/PolicyFailureValueJsonConverter.cs new file mode 100644 index 0000000000..e55623dff0 --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/PolicyFailureValueJsonConverter.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Mxc.Sdk; + +internal sealed class PolicyFailureValueJsonConverter : JsonConverter +{ + public override ulong Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + { + throw new JsonException("policy-failure values must be unsigned 64-bit decimal strings"); + } + var text = reader.GetString(); + if (string.IsNullOrEmpty(text) + || text.Any(static character => character is < '0' or > '9') + || !ulong.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out var value)) + { + throw new JsonException("policy-failure values must be unsigned 64-bit decimal strings"); + } + return value; + } + + public override void Write(Utf8JsonWriter writer, ulong value, JsonSerializerOptions options) => + writer.WriteStringValue(value.ToString(CultureInfo.InvariantCulture)); +} diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/Dev/MxcContainer.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/Dev/MxcContainer.cs index cd032cff23..85c8c792a6 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/Dev/MxcContainer.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/Dev/MxcContainer.cs @@ -2,13 +2,15 @@ // Licensed under the MIT License. using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; using Microsoft.Mxc.Sdk; using Microsoft.Mxc.Sdk.Native; using NativeSandbox = Microsoft.Mxc.Sdk.Native.MxcSandbox; namespace Microsoft.Mxc.Sdk.V1.Dev; -/// One-shot caller-authored exact-JSON entry points. +/// Creation entry points for caller-authored MXC request JSON. public static class MxcContainer { static MxcContainer() => NativeLibraryResolver.Initialize(); @@ -25,26 +27,7 @@ public static ExecutionResult RunJson(string json, JsonOptions? options = null) requestPtr, options?.Experimental == true ? 1 : 0, &result); try { - if (status != (int)ErrorCode.Success) - { - throw NativeError.ToException(status, result.error, "running exact JSON failed"); - } - - var metadataJson = PtrToString(result.output_metadata_json_utf8); - var warningsJson = PtrToString(result.warnings_json_utf8); - return new ExecutionResult - { - ExitCode = result.exit_code, - TimedOut = result.timed_out != 0, - Stdout = PtrToString(result.stdout_utf8) ?? string.Empty, - Stderr = PtrToString(result.stderr_utf8) ?? string.Empty, - OutputMetadata = string.IsNullOrEmpty(metadataJson) - ? null : MxcJson.Deserialize(metadataJson), - Warnings = string.IsNullOrEmpty(warningsJson) - ? Array.Empty() : MxcJson.Deserialize(warningsJson) - ?? throw new MxcException(ErrorCode.BackendError, - "native runtime returned null warnings"), - }; + return ReadRunResult(status, result); } finally { @@ -54,6 +37,30 @@ public static ExecutionResult RunJson(string json, JsonOptions? options = null) } } + internal static unsafe ExecutionResult ReadRunResult(int status, MxcRunResult result) + { + if (status != (int)ErrorCode.Success) + { + throw NativeError.ToException(status, result.error, "running exact JSON failed"); + } + + var metadataJson = PtrToString(result.output_metadata_json_utf8); + var warningsJson = PtrToString(result.warnings_json_utf8); + return new ExecutionResult + { + ExitCode = result.exit_code, + TimedOut = result.timed_out != 0, + Stdout = PtrToString(result.stdout_utf8) ?? string.Empty, + Stderr = PtrToString(result.stderr_utf8) ?? string.Empty, + OutputMetadata = string.IsNullOrEmpty(metadataJson) + ? null : MxcJson.Deserialize(metadataJson), + Warnings = string.IsNullOrEmpty(warningsJson) + ? Array.Empty() : MxcJson.Deserialize(warningsJson) + ?? throw new MxcException(ErrorCode.BackendError, + "native runtime returned null warnings"), + }; + } + public static Task RunJsonAsync( string json, JsonOptions? options = null, @@ -95,6 +102,108 @@ public static Task SpawnJsonAsync( Microsoft.Mxc.Sdk.V1.MxcLifecycle.RunBlockingOperationAsync( () => SpawnJson(json, options), late => late.Dispose(), cancellationToken); + /// Prepare restrictive policy changes without creating a container. + /// The caller decides whether to accept the changes and execute RequestJson. + public static PreparedPolicyRequest PreparePolicyJson( + string json, + PolicyFailures failures, + PolicyPreparationOptions? options = null) + { + ArgumentNullException.ThrowIfNull(json); + ArgumentNullException.ThrowIfNull(failures); + byte[] request; + try + { + PolicyPreparationJson.ValidateRequestSize(json); + (request, _) = DevJsonRequest.Prepare(json, nameof(PreparePolicyJson)); + } + catch (JsonException) + { + throw new MxcException(ErrorCode.MalformedRequest, + "invalid MXC request JSON for policy preparation"); + } + catch (MxcException error) when (error.Code == ErrorCode.MalformedRequest) + { + throw new MxcException(ErrorCode.MalformedRequest, + "invalid MXC request JSON for policy preparation"); + } + + byte[] failuresJson; + try + { + failuresJson = Encoding.UTF8.GetBytes(failures.ToJson() + "\0"); + } + catch (JsonException) + { + throw new MxcException(ErrorCode.MalformedRequest, + "invalid PolicyFailures for policy preparation"); + } + + unsafe + { + fixed (byte* requestPtr = request) + fixed (byte* failuresPtr = failuresJson) + { + byte* output = null; + MxcErrorDetail error = default; + try + { + var status = NativeMethods.mxc_prepare_policy_json( + requestPtr, failuresPtr, options?.Experimental == true ? 1 : 0, + &output, &error); + if (status != (int)ErrorCode.Success) + { + throw NativeError.ToException(status, error, "preparing policy JSON failed"); + } + return ReadPreparedPolicyRequest(output); + } + finally + { + NativeMethods.mxc_string_free(output); + NativeMethods.mxc_error_detail_free(&error); + } + } + } + } + + /// Prepare policy JSON off the caller's thread; cancellation stops awaiting the operation. + public static Task PreparePolicyJsonAsync( + string json, + PolicyFailures failures, + PolicyPreparationOptions? options = null, + CancellationToken cancellationToken = default) => + Microsoft.Mxc.Sdk.V1.MxcLifecycle.RunBlockingOperationAsync( + () => PreparePolicyJson(json, failures, options), _ => { }, cancellationToken); + + internal static unsafe PreparedPolicyRequest ReadPreparedPolicyRequest(byte* output) + { + if (output is null) + { + throw new MxcException(ErrorCode.BackendError, + "native policy preparation returned no JSON"); + } + var length = 0; + while (length <= PolicyPreparationJson.MaximumResultBytes && output[length] != 0) + { + length++; + } + if (length > PolicyPreparationJson.MaximumResultBytes) + { + throw new MxcException(ErrorCode.BackendError, + "native policy preparation result exceeds the byte limit"); + } + try + { + return PreparedPolicyRequest.FromJson( + PolicyPreparationJson.StrictUtf8.GetString(new ReadOnlySpan(output, length))); + } + catch (Exception error) when (error is JsonException or DecoderFallbackException) + { + throw new MxcException(ErrorCode.BackendError, + "native policy preparation returned invalid JSON"); + } + } + public static MxcPtyProcess SpawnWithPtyJson(string json, PtyJsonOptions? options = null) { var (request, timeout) = DevJsonRequest.Prepare(json, nameof(SpawnWithPtyJson)); diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/Dev/PolicyFailures.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/Dev/PolicyFailures.cs new file mode 100644 index 0000000000..63f5d09757 --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/Dev/PolicyFailures.cs @@ -0,0 +1,344 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Buffers; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Mxc.Sdk.V1.Dev; + +/// Known policy-failure status strings for MxcException.NativeCode. +public static class PolicyFailureNativeCodes +{ + /// HRESULT_FROM_WIN32(ERROR_ACCESS_DISABLED_BY_POLICY), formatted as an SDK native code. + public const string PolicyBlocked = "0x800704EC"; +} + +/// Known native outcomes. Outcome properties also retain unknown uint values. +public static class PolicyFailureOutcomes +{ + /// Unspecified outcome, not a policy approval. + public const uint Unknown = 0; + /// Creation was blocked by policy. + public const uint Blocked = 1; + /// Policy evaluation failed. + public const uint EvaluationFailed = 2; +} + +/// Known native failure classes. +public static class PolicyFailureClasses +{ + /// No policy domain specified. + public const uint None = 0; + /// Network policy. + public const uint Network = 1; + /// Filesystem policy. + public const uint Filesystem = 2; + /// Enforcement-mode policy. + public const uint EnforcementMode = 3; +} + +/// Known native failure reasons. +public static class PolicyFailureReasons +{ + /// No failure reason specified. + public const uint None = 0; + /// A requested capability is not allowed. + public const uint CapabilityNotAllowed = 1; + /// Requested access exceeds the policy ceiling. + public const uint AccessExceedsCeiling = 2; + /// Policy requires a deny entry. + public const uint DenyRequired = 3; + /// The request exceeds the policy path limit. + public const uint RequestPathLimit = 4; +} + +/// Known suggested changes, not authorization to apply them. +public static class PolicyFailureActions +{ + /// No request change suggested. + public const uint None = 0; + /// Remove a requested capability. + public const uint RemoveCapability = 1; + /// Restrict requested filesystem access. + public const uint RestrictFilesystemAccess = 2; + /// Reduce the requested path count. + public const uint ReducePathCount = 3; +} + +/// Known native resource categories. +public static class PolicyFailureResourceKinds +{ + /// No resource category specified. + public const uint None = 0; + /// A filesystem path. + public const uint Path = 1; + /// A capability name. + public const uint Capability = 2; +} + +/// Known native value categories. +public static class PolicyFailureValueKinds +{ + /// No value category specified. + public const uint None = 0; + /// Values describe access; compare with . + public const uint Access = 1; + /// Values describe path counts. + public const uint PathCount = 2; +} + +/// Access values for requested/required values when ValueKind is Access. +public static class PolicyFailureAccessValues +{ + /// No access. + public const ulong None = 0; + /// Read-only access. + public const ulong ReadOnly = 1; + /// Read-write access. + public const ulong ReadWrite = 2; +} + +/// Known native flag bits. Flags properties also retain unknown bits. +public static class PolicyFailureFlags +{ + /// No flags set. + public const uint None = 0; + /// A typed remedy is identified, not necessarily safe or available. + public const uint HasActionableDetails = 1; + /// The resource text is complete. + public const uint ResourceComplete = 2; + /// The resource text is unavailable. + public const uint ResourceUnavailable = 4; +} + +/// Caller-owned creation-policy observations, not a policy approval. +[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +public sealed class PolicyFailures +{ + private const int MaximumStorageBytes = 4 * 1024 * 1024 + 512 * 1024; + + /// The MXC policy-failure format version, independent of native versions. + [JsonRequired] + public uint FormatVersion { get; init; } = 1; + + /// Native refusal batches, preserving their boundaries and order. + [JsonRequired] + public PolicyFailureResult[] Results { get; init; } = []; + + /// Serialize observations for application-owned storage without reflection. + /// The observations have an unsupported format or exceed storage bounds. + public string ToJson() + { + ValidateStorage(); + var json = MxcJson.Serialize(this, MxcJson.Options); + ValidateSize(json); + return json; + } + + /// Read stored observations without interpreting native policy requirements. + /// The JSON has an invalid shape, unsupported format, or exceeds storage bounds. + public static PolicyFailures FromJson(string json) + { + ArgumentNullException.ThrowIfNull(json); + try + { + ValidateSize(json); + using (var document = JsonDocument.Parse(json)) + { + MxcJson.RejectDuplicateProperties(document.RootElement); + } + var failures = MxcJson.Deserialize(json) + ?? throw new JsonException("policy failures must be a JSON object"); + failures.ValidateStorage(); + return failures; + } + catch (JsonException) + { + throw new JsonException("invalid stored policy failures"); + } + } + + /// Read Details.policyFailures, or return null when it is absent. + /// + /// Present observations are invalid or exceed storage bounds; + /// is . + /// + public static PolicyFailures? FromException(MxcException exception) + { + ArgumentNullException.ThrowIfNull(exception); + if (exception.Details is not { ValueKind: JsonValueKind.Object } details + || !details.TryGetProperty("policyFailures", out var failures)) + { + return null; + } + try + { + return FromJson(failures.GetRawText()); + } + catch (JsonException) + { + throw new MxcException(ErrorCode.BackendError, "native policy failures are malformed"); + } + } + + /// Return an owned merge of distinct complete batches in first-observed order. + /// Either input or the complete merge exceeds storage bounds or is invalid. + public PolicyFailures MergedWith(PolicyFailures other) + { + ArgumentNullException.ThrowIfNull(other); + var first = FromJson(ToJson()); + var second = FromJson(other.ToJson()); + var batches = new List(); + var seen = new HashSet(StringComparer.Ordinal); + foreach (var result in first.Results.Concat(second.Results)) + { + if (seen.Add(MxcJson.Serialize(result, MxcJson.Options))) + { + batches.Add(result); + } + } + var merged = new PolicyFailures { Results = batches.ToArray() }; + _ = merged.ToJson(); + return merged; + } + + private static void ValidateSize(string json) + { + if (Encoding.UTF8.GetByteCount(json) > MaximumStorageBytes) + { + throw new JsonException("policy failures exceed the storage byte limit"); + } + } + + private void ValidateStorage() + { + if (FormatVersion != 1) + { + throw new JsonException("unsupported policy-failure formatVersion"); + } + // A terminal report can contain one native batch beyond the invocation limits. + if (Results is null || Results.Length > 65) + { + throw new JsonException("policy failures exceed the storage result limit or have null results"); + } + + var details = 0; + long resourceUnits = 0; + foreach (var result in Results) + { + if (result?.Details is null || result.Details.Length > 64) + { + throw new JsonException("policy failures have an invalid result or exceed the per-result detail limit"); + } + details += result.Details.Length; + long resultUnits = 0; + foreach (var detail in result.Details) + { + if (detail is null) + { + throw new JsonException("policy failures contain a null detail"); + } + if (detail.Resource is { } resource) + { + resultUnits += resource.LongLength + 1; + } + } + if (resultUnits > 32_768) + { + throw new JsonException("policy failures exceed the per-result resource limit"); + } + resourceUnits += resultUnits; + } + if (details > 1088 || resourceUnits > 294_912) + { + throw new JsonException("policy failures exceed the total storage detail/resource limit"); + } + } +} + +/// One native creation-policy result batch. +[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +public sealed class PolicyFailureResult +{ + /// The native policy-result contract version. + [JsonRequired] + public uint Version { get; init; } = 1; + + /// The minimum native reader version. + [JsonRequired] + public uint MinimumReaderVersion { get; init; } = 1; + + /// The native outcome; compare with . Unknown values are retained. + [JsonRequired] + public uint Outcome { get; init; } + + /// The observations belonging to this batch. + [JsonRequired] + public PolicyFailureDetail[] Details { get; init; } = []; +} + +/// A native policy observation with lossless numeric and resource data. +[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +public sealed class PolicyFailureDetail +{ + /// The raw native failure class. + [JsonRequired] + public uint FailureClass { get; init; } + + /// The raw native failure reason. + [JsonRequired] + public uint FailureReason { get; init; } + + /// The raw native required action; the SDK does not apply it. + [JsonRequired] + public uint RequiredAction { get; init; } + + /// The raw native resource discriminator. + [JsonRequired] + public uint ResourceKind { get; init; } + + /// The raw native value discriminator. + [JsonRequired] + public uint ValueKind { get; init; } + + /// The raw native flags, including unknown bits. + [JsonRequired] + public uint Flags { get; init; } + + /// The observed requested value, stored as an unsigned decimal string in JSON. + [JsonRequired] + [JsonConverter(typeof(PolicyFailureValueJsonConverter))] + public ulong RequestedValue { get; init; } + + /// The observed required value, stored as an unsigned decimal string in JSON. + [JsonRequired] + [JsonConverter(typeof(PolicyFailureValueJsonConverter))] + public ulong RequiredValue { get; init; } + + /// Optional UTF-16 code units without a terminator. Null differs from empty or malformed data. + public ushort[]? Resource { get; init; } + + /// Decode resource text strictly; this does not establish completeness or authorize a policy change. + /// The resource contains malformed UTF-16. + public string? GetResourceText() + { + if (Resource is null) + { + return null; + } + var text = new string(MemoryMarshal.Cast(Resource.AsSpan())); + var remaining = text.AsSpan(); + while (!remaining.IsEmpty) + { + if (Rune.DecodeFromUtf16(remaining, out _, out var consumed) != OperationStatus.Done) + { + throw new DecoderFallbackException("policy resource is not valid UTF-16"); + } + remaining = remaining[consumed..]; + } + return text; + } +} diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/Dev/PolicyPreparationOptions.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/Dev/PolicyPreparationOptions.cs new file mode 100644 index 0000000000..42e8c9459d --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/Dev/PolicyPreparationOptions.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Mxc.Sdk.V1.Dev; + +/// Invocation controls for preparing caller-authored MXC request JSON. +public sealed class PolicyPreparationOptions +{ + /// Authorize experimental backends independently of the JSON version. + public bool Experimental { get; set; } +} diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/Dev/PreparedPolicyRequest.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/Dev/PreparedPolicyRequest.cs new file mode 100644 index 0000000000..64d833dc28 --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/Dev/PreparedPolicyRequest.cs @@ -0,0 +1,224 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Mxc.Sdk.V1.Dev; + +/// The kind of change to a JSON value in an MXC request. +[JsonConverter(typeof(PolicyChangeOperationJsonConverter))] +public enum PolicyChangeOperation +{ + /// Add a previously absent value; Before is JSON null. + Add, + /// Remove a present value; After is JSON null. + Remove, + /// Replace a present value, which may itself be JSON null. + Replace, +} + +/// One proposed change, with owned JSON values and an RFC 6901 JSON Pointer. +[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +public sealed class PolicyChange +{ + // .NET 8 source generation assigns Undefined before checking required fields. + private JsonElement before; + private JsonElement after; + + /// The operation distinguishing absent values from actual JSON null values. + [JsonRequired] + public PolicyChangeOperation Operation { get; init; } + + /// The changed value's RFC 6901 JSON Pointer. + [JsonRequired] + public string Path { get; init; } = string.Empty; + + /// The original JSON value, or JSON null for Add. + [JsonRequired] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public JsonElement Before + { + get => before; + init => before = value.ValueKind == JsonValueKind.Undefined ? value : value.Clone(); + } + + /// The prepared JSON value, or JSON null for Remove. + [JsonRequired] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public JsonElement After + { + get => after; + init => after = value.ValueKind == JsonValueKind.Undefined ? value : value.Clone(); + } + + internal void Validate() + { + if (Operation is not (PolicyChangeOperation.Add or PolicyChangeOperation.Remove or PolicyChangeOperation.Replace) + || Before.ValueKind == JsonValueKind.Undefined || After.ValueKind == JsonValueKind.Undefined + || (Operation == PolicyChangeOperation.Add && Before.ValueKind != JsonValueKind.Null) + || (Operation == PolicyChangeOperation.Remove && After.ValueKind != JsonValueKind.Null)) + { + throw new JsonException("invalid policy change operation or values"); + } + if (Path is null || (Path.Length != 0 && Path[0] != '/')) + { + throw new JsonException("invalid policy change JSON Pointer"); + } + _ = PolicyPreparationJson.StrictUtf8.GetByteCount(Path); + for (var index = 0; index < Path.Length; index++) + { + if (Path[index] == '~' && (++index == Path.Length || Path[index] is not ('0' or '1'))) + { + throw new JsonException("invalid policy change JSON Pointer escape"); + } + } + } +} + +/// A prepared MXC request and proposed changes for the caller to accept or reject. +[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +public sealed class PreparedPolicyRequest +{ + /// The complete prepared MXC request JSON; preparation does not execute it. + [JsonRequired] + public string RequestJson { get; init; } = string.Empty; + + /// The proposed changes in application order. + [JsonRequired] + public PolicyChange[] Changes { get; init; } = []; + + /// Serialize the prepared request for application-owned storage without reflection. + /// The prepared request is malformed or exceeds storage limits. + public string ToJson() + { + try + { + ValidateStorage(); + var json = MxcJson.Serialize(this, PolicyPreparationJson.SerializerOptions); + PolicyPreparationJson.ValidateResultSize(json); + return json; + } + catch (Exception error) when (error is JsonException or EncoderFallbackException) + { + throw new JsonException("invalid prepared policy request"); + } + } + + /// Read stored preparation data without approving its changes or validating backend policy. + /// The JSON is malformed or exceeds storage limits. + public static PreparedPolicyRequest FromJson(string json) + { + ArgumentNullException.ThrowIfNull(json); + try + { + PolicyPreparationJson.ValidateResultSize(json); + using (var document = JsonDocument.Parse(json, PolicyPreparationJson.DocumentOptions)) + { + MxcJson.RejectDuplicateProperties(document.RootElement); + } + var prepared = MxcJson.Deserialize( + json, PolicyPreparationJson.SerializerOptions) + ?? throw new JsonException("prepared policy request must be an object"); + prepared.ValidateStorage(); + return prepared; + } + catch (Exception error) when (error is JsonException or EncoderFallbackException) + { + throw new JsonException("invalid prepared policy request"); + } + } + + private void ValidateStorage() + { + if (RequestJson is null) + { + throw new JsonException("prepared policy request has null requestJson"); + } + PolicyPreparationJson.ValidateRequestSize(RequestJson, PolicyPreparationJson.MaximumResultBytes); + using (var document = JsonDocument.Parse(RequestJson, new JsonDocumentOptions { MaxDepth = 128 })) + { + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + throw new JsonException("prepared MXC request must be an object"); + } + } + if (Changes is null || Changes.Length > PolicyPreparationJson.MaximumChanges) + { + throw new JsonException("prepared policy request has invalid changes or exceeds the change limit"); + } + foreach (var change in Changes) + { + if (change is null) + { + throw new JsonException("prepared policy request has a null change"); + } + change.Validate(); + } + } +} + +internal sealed class PolicyChangeOperationJsonConverter : JsonConverter +{ + public override PolicyChangeOperation Read( + ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + if (reader.ValueTextEquals("add")) return PolicyChangeOperation.Add; + if (reader.ValueTextEquals("remove")) return PolicyChangeOperation.Remove; + if (reader.ValueTextEquals("replace")) return PolicyChangeOperation.Replace; + } + throw new JsonException("invalid policy change operation"); + } + + public override void Write( + Utf8JsonWriter writer, PolicyChangeOperation value, JsonSerializerOptions options) => + writer.WriteStringValue(value switch + { + PolicyChangeOperation.Add => "add", + PolicyChangeOperation.Remove => "remove", + PolicyChangeOperation.Replace => "replace", + _ => throw new JsonException("invalid policy change operation"), + }); +} + +internal static class PolicyPreparationJson +{ + internal const int MaximumInputBytes = 8 * 1024 * 1024; + internal const int MaximumResultBytes = 32 * 1024 * 1024; + internal const int MaximumChanges = 4096; + internal static readonly Encoding StrictUtf8 = new UTF8Encoding(false, true); + // JSON values can include the request's full nesting beneath the result envelope. + internal static readonly JsonSerializerOptions SerializerOptions = new(MxcJson.Options) + { + MaxDepth = 256, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + internal static readonly JsonDocumentOptions DocumentOptions = new() { MaxDepth = 256 }; + + internal static void ValidateRequestSize(string json, int maximumBytes = MaximumInputBytes) + { + try + { + if (json.Contains('\0') || StrictUtf8.GetByteCount(json) > maximumBytes) + { + throw new JsonException("invalid policy preparation request or request byte limit exceeded"); + } + } + catch (EncoderFallbackException) + { + throw new JsonException("invalid policy preparation request encoding"); + } + } + + internal static void ValidateResultSize(string json) + { + if (StrictUtf8.GetByteCount(json) > MaximumResultBytes) + { + throw new JsonException("policy preparation result byte limit exceeded"); + } + } +} diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcException.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcException.cs index 02a6825f84..b1511ddcc4 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcException.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcException.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Text.Json; + namespace Microsoft.Mxc.Sdk.V1; /// @@ -38,6 +40,9 @@ public sealed class MxcException : Exception /// public string? Remediation { get; } + /// Optional backend details, owned independently of native memory and JSON documents. + public JsonElement? Details { get; } + /// Create an exception with the given code and message. public MxcException(ErrorCode code, string message) : this(code, message, null, null, null) @@ -53,13 +58,15 @@ internal MxcException( string message, string? operation, string? nativeCode, - string? remediation) + string? remediation, + JsonElement? details = null) : base(message) { Code = code; Operation = operation; NativeCode = nativeCode; Remediation = remediation; + Details = details?.Clone(); } /// diff --git a/sdk/dotnet/README.md b/sdk/dotnet/README.md index 68801c275f..4caaa7f5b8 100644 --- a/sdk/dotnet/README.md +++ b/sdk/dotnet/README.md @@ -249,9 +249,11 @@ for complete signatures and types. ## Errors, warnings, and discovery Native failures are surfaced as `MxcException`; inspect `Code`, `Operation`, -`NativeCode`, and `Remediation` when available. Security and operational -warnings are available on `ExecutionResult.Warnings` and `MxcProcess.Warnings`. -Provision returns identity, optional typed metadata, and `ProvisionResult.Warnings`. +`NativeCode`, `Remediation`, and optional `Details` when available. `Details` +is a `JsonElement` that remains valid after native cleanup. Security and +operational warnings are available on `ExecutionResult.Warnings` and +`MxcProcess.Warnings`. Provision returns identity, optional typed metadata, +and `ProvisionResult.Warnings`. Start, stop, and deprovision return `LifecycleResult.Warnings`. Omitted native warnings become an empty array; malformed warnings fail explicitly. When IsolationSession provision metadata is present, `AgentUserName`, `AgentUserSid`, @@ -283,6 +285,89 @@ inspect ACLs. `GetUserProfilePolicy` uses the supplied environment, and `GetTemporaryFilesPolicy` returns existing temporary storage without creating directories. +## Policy preparation and caller-owned observations + +`Microsoft.Mxc.Sdk.V1.Dev.MxcContainer.PreparePolicyJson` and +`PreparePolicyJsonAsync` take MXC request JSON, saved `PolicyFailures`, and +optional `PolicyPreparationOptions`. Preparation validates supported +restrictive changes for Windows ProcessContainer without creating a container. +It returns an owned `PreparedPolicyRequest` with `RequestJson` and `Changes`. +The application inspects and explicitly accepts the changes before passing +`RequestJson` to ordinary strict creation. Each creation call makes one native +creation attempt and executes only the supplied request. MXC keeps no +observations between calls. + +```csharp +using Microsoft.Mxc.Sdk.V1; +using Dev = Microsoft.Mxc.Sdk.V1.Dev; + +const string requestJson = """ + {"version":"1.1.0-alpha","containment":"processcontainer", + "process":{"commandLine":"cmd.exe /c echo hello","timeout":30000}} + """; +Dev.PolicyFailures saved = Dev.PolicyFailures.FromJson( + await File.ReadAllTextAsync("policy-failures.json")); +Dev.PreparedPolicyRequest prepared = await Dev.MxcContainer.PreparePolicyJsonAsync( + requestJson, saved); + +await File.WriteAllTextAsync("prepared-request.json", prepared.ToJson()); +Console.WriteLine("Review prepared-request.json. Type 'accept' to execute its request."); +if (Console.ReadLine() != "accept") +{ + return; +} + +try +{ + ExecutionResult result = await Dev.MxcContainer.RunJsonAsync(prepared.RequestJson); + Console.WriteLine(result.Stdout); +} +catch (MxcException error) +{ + if (Dev.PolicyFailures.FromException(error) is { } current) + { + await File.WriteAllTextAsync("latest-policy-failures.json", current.ToJson()); + Dev.PolicyFailures combined = saved.MergedWith(current); + await File.WriteAllTextAsync("policy-failures.json", combined.ToJson()); + } + throw; +} +``` + +`FromException` reads that creation attempt's observations from +`MxcException.Details` without changing its code, message, or other backend +details. `ToJson` and `FromJson` are public, source-generated JSON helpers +that also work with Native AOT. They preserve native batch boundaries, unknown +numeric codes, lossless `ulong` values as decimal strings, and optional +`ushort[]` resources. +`PolicyFailureDetail.GetResourceText()` throws `DecoderFallbackException` for +malformed UTF-16 while leaving the raw units available. +Named groups such as `Dev.PolicyFailureOutcomes.Blocked` and +`Dev.PolicyFailureReasons.CapabilityNotAllowed` make comparisons readable +without changing numeric JSON or discarding unknown values. +Policy-specific resources, capabilities, and requested or required values +remain structured rather than appearing in exception text, stacks, or warnings. + +`MergedWith` validates both inputs and returns an owned collection of distinct +complete batches in first-observed order. It never modifies either input or +truncates an overflowing merge. If merging fails, keep the original reports +separately and stop rather than discarding observations. Storage and application +retry budgets are caller responsibilities. + +Every `PolicyChange` contains an RFC 6901 JSON Pointer, a named operation +(`add`, `remove`, or `replace` in JSON), and required `Before` and `After` +`JsonElement` values. Add uses JSON null for an absent `Before`; Remove uses +JSON null for an absent `After`. Replace can replace an actual JSON null value. +`PreparedPolicyRequest.ToJson` and `FromJson` support application-owned storage; +reading stored preparation data does not approve its changes. + +`JsonOptions` contains only `Experimental`. Preparation uses its own +`PolicyPreparationOptions.Experimental`; experimental authorization does not +grant policy approval. Successful execution keeps the ordinary result, stdin, +cancellation, warning, and ownership behavior. See the +[V1.Dev reference](https://github.com/microsoft/mxc/blob/main/docs/api-reference/dotnet/v1/dev.md#policy-preparation-and-caller-owned-observations) +for preparation and storage limits. + ## Package and native library The package includes the native runtime assets for supported platforms. diff --git a/sdk/node/README.md b/sdk/node/README.md index a8cb95160f..c61baf1258 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -248,11 +248,59 @@ backend guides under [`docs/`](https://github.com/microsoft/mxc/tree/main/docs). See the [SDK API reference](https://github.com/microsoft/mxc/blob/main/docs/api-reference/README.md) for complete signatures and types. +## Separate policy preparation (V1.Dev) + +On a supported native creation-policy refusal, `policyFailuresFromError(error)` +from `@microsoft/mxc-sdk/v1/dev` extracts owned observations from +`MxcError.details.policyFailures`. `parsePolicyFailures` and +`serializePolicyFailures` support application-owned storage; +`mergePolicyFailures(older, newer)` combines complete batches in first-seen +order, leaves both inputs unchanged, and never truncates over-limit +observations. + +`preparePolicyJson(requestJson, failures, options?)` returns a candidate +`PreparedPolicyRequest` with `requestJson` and ordered `changes`. Preparation +runs native filesystem work asynchronously, creates no container, and leaves +both inputs unchanged. The application inspects and accepts the candidate +before submitting it to strict `runJson` or `spawnJson`, whose `JsonOptions` +contain only independent `experimental` authorization. Preparation has its +own `PolicyPreparationOptions`. `preparePolicyJson` is the only operation that +takes observations, and the SDK keeps none between calls. + +```typescript +import { preparePolicyJson, runJson } from '@microsoft/mxc-sdk/v1/dev'; + +const prepared = await preparePolicyJson(requestJson, failures); +// acceptPolicyChange is the application's inspection and approval callback. +if (await acceptPolicyChange(prepared)) { + const result = await runJson(prepared.requestJson); +} +``` + +Named groups such as `PolicyFailureOutcomes.Blocked` and +`PolicyFailureReasons.CapabilityNotAllowed` are exported from the Dev subpath +for readable comparisons. Native codes remain numeric; unsigned 64-bit values +remain decimal strings, and resources retain their UTF-16 code units. +`policyFailureResourceText` decodes text strictly and throws for malformed +UTF-16. Decoded text alone does not establish a safe policy change. The native +backend validates preparation and Windows reevaluates policy during strict +creation. Both `before` and `after` are required JSON values in every change, +including null placeholders +for added or removed fields. `parsePreparedPolicyRequest` and +`serializePreparedPolicyRequest` store checked candidates. See the +[V1.Dev reference](https://github.com/microsoft/mxc/blob/main/docs/api-reference/node/v1/dev.md#separate-policy-preparation) +for models and limits. Preparation is bounded to 8 MiB of input request UTF-8, +32 MiB of result JSON, and 4096 changes. + ## Errors, warnings, and telemetry Native errors are surfaced as `MxcError` with a typed error code and optional -operation, native status, and remediation. Security and operational warnings -are returned in `ExecutionResult.warnings` and `MxcProcess.warnings`. +operation, native status, remediation, and backend-specific `details`. +Native status decoding retains those details alongside `details.ffiStatus`. +`details` can contain policy resource paths; `JSON.stringify(error)` includes +it even though console inspection omits it, so filter `details` before logging. +Security and operational warnings are returned in `ExecutionResult.warnings` +and `MxcProcess.warnings`. Captured `stdout` and `stderr` are workload output; warnings and structured denial-capture metadata are not appended to those streams. Validation warnings are returned in `ValidationResult.warnings`. diff --git a/sdk/node/src/bindings/native-error.ts b/sdk/node/src/bindings/native-error.ts index 406fb05ca0..78df82c23e 100644 --- a/sdk/node/src/bindings/native-error.ts +++ b/sdk/node/src/bindings/native-error.ts @@ -12,6 +12,8 @@ export interface AbiErrorDetail { operation: unknown | null; nativeCode: unknown | null; remediation: unknown | null; + /** Released by the owning result or out-error free, never independently. */ + detailsJson: unknown | null; } export const AbiErrorDetailType = koffi.struct('MxcNodeJsonErrorDetail', { @@ -19,8 +21,13 @@ export const AbiErrorDetailType = koffi.struct('MxcNodeJsonErrorDetail', { operation: 'void *', nativeCode: 'void *', remediation: 'void *', + detailsJson: 'void *', }); +export function createAbiErrorDetail(): AbiErrorDetail { + return { message: null, operation: null, nativeCode: null, remediation: null, detailsJson: null }; +} + export function decodeString(pointer: unknown): string | undefined { if (pointer === null || pointer === undefined || pointer === 0 || pointer === 0n) { return undefined; @@ -56,13 +63,28 @@ export function nativeStatusError( detail: Partial = {}, fallback = `native runtime failed with status ${status}`, ): MxcError { + const json = decodeString(detail.detailsJson); + let details: Record = {}; + if (json !== undefined) { + try { + const value: unknown = JSON.parse(json); + if (!isRecord(value)) throw new Error('expected a JSON object'); + details = value; + } catch (error) { + throw new MxcError({ + code: 'backend_error', + message: 'native runtime returned malformed error details', + details: { ffiStatus: status, cause: error instanceof Error ? error.message : String(error) }, + }); + } + } return new MxcError({ code: _errorCodeForNativeStatus(status), message: decodeString(detail.message) ?? fallback, operation: decodeString(detail.operation), nativeCode: decodeString(detail.nativeCode), remediation: decodeString(detail.remediation), - details: { ffiStatus: status }, + details: { ...details, ffiStatus: status }, }); } diff --git a/sdk/node/src/bindings/policy-preparation.ts b/sdk/node/src/bindings/policy-preparation.ts new file mode 100644 index 0000000000..9bfdec5f42 --- /dev/null +++ b/sdk/node/src/bindings/policy-preparation.ts @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import koffi from 'koffi'; +import { MxcError } from '../v1/errors.js'; +import { loadMxcFfi } from '../native-library.js'; +import { bindNativeFunction } from './native-function.js'; +import { + AbiErrorDetailType, + createAbiErrorDetail, + decodeString, + nativeStatusError, + type AbiErrorDetail, +} from './native-error.js'; + +type PrepareFunction = ( + requestJson: string, + policyFailuresJson: string, + experimental: number, + out: unknown[], + error: AbiErrorDetail, +) => number; +type StringFreeFunction = (value: unknown) => void; +type ErrorFreeFunction = (error: AbiErrorDetail) => void; + +async function prepareBindingPolicyJsonAsyncNative( + requestJson: string, + policyFailuresJson: string, + experimental: boolean, +): Promise { + const native = loadMxcFfi(); + try { + const freeString = bindNativeFunction(native.handle, { + symbol: 'mxc_string_free', + result: 'void', + parameters: ['void *'], + }); + const freeError = bindNativeFunction(native.handle, { + symbol: 'mxc_error_detail_free', + result: 'void', + parameters: [koffi.pointer(AbiErrorDetailType)], + }); + const prepare = bindNativeFunction(native.handle, { + symbol: 'mxc_prepare_policy_json', + result: 'int32_t', + parameters: [ + 'const char *', + 'const char *', + 'int32_t', + // Keep the owned char* opaque for mxc_string_free. + koffi.out(koffi.pointer('void *')), + koffi.out(koffi.pointer(AbiErrorDetailType)), + ], + }); + const out: unknown[] = [null]; + const error = createAbiErrorDetail(); + try { + const status = await new Promise((resolve, reject) => { + prepare.async(requestJson, policyFailuresJson, experimental ? 1 : 0, out, error, + (failure, nativeStatus) => { + if (failure !== null) reject(failure); + else resolve(nativeStatus); + }); + }); + if (status !== 0) { + throw nativeStatusError(status, error, 'native policy preparation failed'); + } + const json = decodeString(out[0]); + if (json === undefined || json.length === 0) { + throw new MxcError('backend_error', 'native policy preparation returned no JSON'); + } + return json; + } finally { + try { + if (out[0] !== null && out[0] !== undefined && out[0] !== 0 && out[0] !== 0n) { + freeString(out[0]); + } + } finally { + freeError(error); + } + } + } finally { + native.handle.unload(); + } +} + +let asyncImplementation = prepareBindingPolicyJsonAsyncNative; + +/** @internal Replaces the async native call for one process's unit tests. */ +export function _setBindingPolicyPreparationAsyncImplementation( + implementation?: typeof prepareBindingPolicyJsonAsyncNative, +): void { + asyncImplementation = implementation ?? prepareBindingPolicyJsonAsyncNative; +} + +/** Prepare on Koffi's worker pool and copy the result before releasing native allocations. */ +export async function prepareBindingPolicyJsonAsync( + requestJson: string, + policyFailuresJson: string, + experimental: boolean, +): Promise { + try { + return await asyncImplementation(requestJson, policyFailuresJson, experimental); + } catch (error) { + if (error instanceof MxcError) throw error; + throw new MxcError('backend_error', 'native policy preparation failed'); + } +} diff --git a/sdk/node/src/bindings/probe.ts b/sdk/node/src/bindings/probe.ts index c8817254f4..4b3b97648c 100644 --- a/sdk/node/src/bindings/probe.ts +++ b/sdk/node/src/bindings/probe.ts @@ -6,6 +6,7 @@ import { MxcError } from '../v1/errors.js'; import { loadMxcFfi } from '../native-library.js'; import { AbiErrorDetailType, + createAbiErrorDetail, decodeString, nativeStatusError, type AbiErrorDetail, @@ -103,7 +104,7 @@ export function readProbeJsonWithNative( decode: (pointer: Pointer) => string | undefined = decodeString, ): string { const output: Pointer[] = [null]; - const error = {} as AbiErrorDetail; + const error = createAbiErrorDetail(); const status = native.probeRequest(requestJson ?? null, output, error); if (status !== 0) { try { diff --git a/sdk/node/src/bindings/pty.ts b/sdk/node/src/bindings/pty.ts index 901e2763af..d52ef5c308 100644 --- a/sdk/node/src/bindings/pty.ts +++ b/sdk/node/src/bindings/pty.ts @@ -8,6 +8,7 @@ import { loadMxcFfi, type MxcNativeLibrary } from '../native-library.js'; import { bindNativeFunction } from './native-function.js'; import { AbiErrorDetailType, + createAbiErrorDetail, nativeStatusError, type AbiErrorDetail, } from './native-error.js'; @@ -175,7 +176,7 @@ async function createPtyFromJson( factory: NativeStreamFactory, ): Promise { const outHandle: Pointer[] = [null]; - const error = {} as AbiErrorDetail; + const error = createAbiErrorDetail(); const status = await new Promise((resolve, reject) => { spawn(outHandle, error, (callError, nativeStatus) => { if (callError !== null) reject(callError); diff --git a/sdk/node/src/bindings/run.ts b/sdk/node/src/bindings/run.ts index 97640aa574..d3b232bdb1 100644 --- a/sdk/node/src/bindings/run.ts +++ b/sdk/node/src/bindings/run.ts @@ -13,6 +13,7 @@ import type { ExecutionMetadata } from '../v1/types.js'; import { bindNativeFunction } from './native-function.js'; import { AbiErrorDetailType, + createAbiErrorDetail, decodeString, nativeStatusError, parseStringArray, @@ -85,6 +86,19 @@ function bindRunFunctions( }; } +function createAbiRunResult(): AbiRunResult { + return { + status: 0, + exitCode: 0, + timedOut: 0, + stdout: null, + stderr: null, + error: createAbiErrorDetail(), + outputMetadata: null, + warnings: null, + }; +} + function decodeRunResult(status: number, result: AbiRunResult): BindingRunResult { if (status !== 0 || result.status !== 0) { throw nativeStatusError(result.status || status, result.error); @@ -114,7 +128,7 @@ function runJsonNative( const native = loadMxcFfi(); try { const { run, free } = bindRunFunctions(native, symbol); - const result = {} as AbiRunResult; + const result = createAbiRunResult(); let filled = false; try { const status = run(requestJson, experimental ? 1 : 0, result); @@ -187,7 +201,7 @@ async function runRawOneShotJsonAsyncNative( const native = loadMxcFfi(); try { const { run, free } = bindRunFunctions(native); - const result = {} as AbiRunResult; + const result = createAbiRunResult(); let filled = false; try { const status = await new Promise((resolve, reject) => { diff --git a/sdk/node/src/bindings/state-aware.ts b/sdk/node/src/bindings/state-aware.ts index d1e6137912..39e3250e3e 100644 --- a/sdk/node/src/bindings/state-aware.ts +++ b/sdk/node/src/bindings/state-aware.ts @@ -9,6 +9,7 @@ import { loadMxcFfi } from '../native-library.js'; import { bindNativeFunction } from './native-function.js'; import { AbiErrorDetailType, + createAbiErrorDetail, decodeString, nativeStatusError, type AbiErrorDetail, @@ -82,12 +83,7 @@ function createStateAwareResult(): StateAwareNativeResult { return { status: 0, responseJsonUtf8: null, - error: { - message: null, - operation: null, - nativeCode: null, - remediation: null, - }, + error: createAbiErrorDetail(), }; } diff --git a/sdk/node/src/bindings/streaming.ts b/sdk/node/src/bindings/streaming.ts index 810b6bfdbc..b3697439e4 100644 --- a/sdk/node/src/bindings/streaming.ts +++ b/sdk/node/src/bindings/streaming.ts @@ -27,6 +27,7 @@ import { } from './native-stdio.js'; import { AbiErrorDetailType, + createAbiErrorDetail, decodeString, nativeStatusError, parseStringArray, @@ -556,7 +557,7 @@ export async function createStreamingDriverFromJson( stdin = false, ): Promise { const outHandle: Pointer[] = [null]; - const error = {} as AbiErrorDetail; + const error = createAbiErrorDetail(); const spawn = stdin ? native.spawnWithStdin.bind(native) : native.spawn.bind(native); const status = await new Promise((resolve, reject) => { spawn( @@ -585,7 +586,7 @@ export function createStreamingDriverSync( experimental = false, ): NativeLifecycleDriver { const outHandle: Pointer[] = [null]; - const error = {} as AbiErrorDetail; + const error = createAbiErrorDetail(); const status = native.spawnSync( JSON.stringify(request), experimental ? 1 : 0, @@ -714,7 +715,7 @@ export function createStateAwareStreamingDriver( factory: NativeStreamFactory, ): NativeLifecycleDriver { const outHandle: Pointer[] = [null]; - const error = {} as AbiErrorDetail; + const error = createAbiErrorDetail(); const status = native.stateAwareExec( requestJson, experimental ? 1 : 0, @@ -784,7 +785,7 @@ export async function createStateAwareStreamingDriverAsync( stdin = false, ): Promise { const outHandle: Pointer[] = [null]; - const error = {} as AbiErrorDetail; + const error = createAbiErrorDetail(); const exec = stdin ? native.stateAwareExecWithStdinAsync.bind(native) : native.stateAwareExecAsync.bind(native); diff --git a/sdk/node/src/v1/dev/index.ts b/sdk/node/src/v1/dev/index.ts index 9f4d3b6a18..5a2110ff66 100644 --- a/sdk/node/src/v1/dev/index.ts +++ b/sdk/node/src/v1/dev/index.ts @@ -20,6 +20,44 @@ import { } from '../../bindings/pty.js'; import { spawnProcessContainerWithPtyJson } from '../../bindings/process-container-pty.js'; import { runBindingStateAwareRequestAsync } from '../../bindings/state-aware.js'; +import { prepareBindingPolicyJsonAsync } from '../../bindings/policy-preparation.js'; +import { serializePolicyFailures, type PolicyFailures } from './policy-failures.js'; +import { + checkPolicyPreparationRequest, + parsePreparedPolicyRequest, + type PolicyPreparationOptions, + type PreparedPolicyRequest, +} from './policy-preparation.js'; + +export { + PolicyFailureNativeCodes, + PolicyFailureOutcomes, + PolicyFailureClasses, + PolicyFailureReasons, + PolicyFailureActions, + PolicyFailureResourceKinds, + PolicyFailureValueKinds, + PolicyFailureAccessValues, + PolicyFailureFlags, + parsePolicyFailures, + serializePolicyFailures, + mergePolicyFailures, + policyFailuresFromError, + policyFailureResourceText, + type PolicyFailures, + type PolicyFailureResult, + type PolicyFailureDetail, +} from './policy-failures.js'; + +export { + parsePreparedPolicyRequest, + serializePreparedPolicyRequest, + type PolicyPreparationOptions, + type PreparedPolicyRequest, + type PolicyPreparationChange, + type JsonPointer, + type JsonValue, +} from './policy-preparation.js'; /** Authorization supplied independently of the JSON contract version. */ export interface JsonOptions { @@ -144,6 +182,24 @@ export async function spawnJson(json: string, options: JsonOptions = {}): Promis return spawnBindingSandboxProcessJson(json, options.experimental === true, timeoutMs); } +/** Prepare a candidate without creating a container or changing either input. */ +export async function preparePolicyJson( + requestJson: string, + failures: PolicyFailures, + options: PolicyPreparationOptions = {}, +): Promise { + checkOptions('preparePolicyJson', options); + checkPolicyPreparationRequest(requestJson); + const json = await prepareBindingPolicyJsonAsync( + requestJson, serializePolicyFailures(failures), options.experimental === true, + ); + try { + return parsePreparedPolicyRequest(json); + } catch { + throw new MxcError('backend_error', 'native policy preparation returned a malformed result'); + } +} + /** Spawn a one-shot request with a PTY. */ export async function spawnWithPtyJson( json: string, diff --git a/sdk/node/src/v1/dev/json-duplicate-keys.ts b/sdk/node/src/v1/dev/json-duplicate-keys.ts new file mode 100644 index 0000000000..90daa04bb4 --- /dev/null +++ b/sdk/node/src/v1/dev/json-duplicate-keys.ts @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Report whether JSON text that `JSON.parse` accepted repeats a decoded key + * within one object. `JSON.parse` keeps the last duplicate silently. + */ +export function hasDuplicateJsonKeys(json: string): boolean { + // One entry per open container: the keys seen so far, or null for an array. + const stack: Array | null> = []; + let expectKey = false; + for (let index = 0; index < json.length; index += 1) { + const char = json[index]; + if (char === '"') { + const start = index; + for (index += 1; index < json.length && json[index] !== '"'; index += 1) { + if (json[index] === '\\') index += 1; + } + const keys = stack.at(-1); + if (keys && expectKey) { + const key = JSON.parse(json.slice(start, index + 1)) as string; + if (keys.has(key)) return true; + keys.add(key); + expectKey = false; + } + } else if (char === '{' || char === '[') { + stack.push(char === '{' ? new Set() : null); + expectKey = char === '{'; + } else if (char === '}' || char === ']') { + stack.pop(); + expectKey = false; + } else if (char === ',') { + expectKey = Boolean(stack.at(-1)); + } + } + return false; +} diff --git a/sdk/node/src/v1/dev/policy-failures.ts b/sdk/node/src/v1/dev/policy-failures.ts new file mode 100644 index 0000000000..0d28623f18 --- /dev/null +++ b/sdk/node/src/v1/dev/policy-failures.ts @@ -0,0 +1,292 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { MxcError } from '../errors.js'; +import { hasDuplicateJsonKeys } from './json-duplicate-keys.js'; + +/** Native status strings for MxcError.nativeCode. */ +export const PolicyFailureNativeCodes = Object.freeze({ + /** HRESULT_FROM_WIN32(ERROR_ACCESS_DISABLED_BY_POLICY), formatted as an SDK native code. */ + PolicyBlocked: '0x800704EC', +} as const); + +/** Known native outcomes; outcome fields also retain unknown numbers. */ +export const PolicyFailureOutcomes = Object.freeze({ + Unknown: 0, + Blocked: 1, + EvaluationFailed: 2, +} as const); + +/** Known native failure classes. */ +export const PolicyFailureClasses = Object.freeze({ + None: 0, + Network: 1, + Filesystem: 2, + EnforcementMode: 3, +} as const); + +/** Known native failure reasons. */ +export const PolicyFailureReasons = Object.freeze({ + None: 0, + CapabilityNotAllowed: 1, + AccessExceedsCeiling: 2, + DenyRequired: 3, + RequestPathLimit: 4, +} as const); + +/** Known suggested changes, not authorization to apply them. */ +export const PolicyFailureActions = Object.freeze({ + None: 0, + RemoveCapability: 1, + RestrictFilesystemAccess: 2, + ReducePathCount: 3, +} as const); + +/** Known native resource categories. */ +export const PolicyFailureResourceKinds = Object.freeze({ + None: 0, + Path: 1, + Capability: 2, +} as const); + +/** Known native value categories. */ +export const PolicyFailureValueKinds = Object.freeze({ + None: 0, + Access: 1, + PathCount: 2, +} as const); + +/** Decimal-string access values for requestedValue/requiredValue when valueKind is Access. */ +export const PolicyFailureAccessValues = Object.freeze({ + None: '0', + ReadOnly: '1', + ReadWrite: '2', +} as const); + +/** Known native flag bits; flags fields also retain unknown bits. */ +export const PolicyFailureFlags = Object.freeze({ + None: 0, + HasActionableDetails: 1, + ResourceComplete: 2, + ResourceUnavailable: 4, +} as const); + +/** Creation-policy observations, not a policy approval. */ +export interface PolicyFailures { + formatVersion: number; + results: PolicyFailureResult[]; +} + +/** One native refusal batch. Unknown numeric codes remain inspectable. */ +export interface PolicyFailureResult { + version: number; + minimumReaderVersion: number; + outcome: number; + details: PolicyFailureDetail[]; +} + +/** A native observation; values describe the request Windows evaluated. */ +export interface PolicyFailureDetail { + failureClass: number; + failureReason: number; + requiredAction: number; + resourceKind: number; + valueKind: number; + flags: number; + requestedValue: string; + requiredValue: string; + /** UTF-16 code units without a terminator. Absence differs from an empty array. */ + resource?: number[]; +} + +// Storage includes one final native batch beyond the limits for preparation. +const MAX_STORAGE_BYTES = 4.5 * 1024 * 1024; +const MAX_STORAGE_RESULTS = 65; +const MAX_STORAGE_DETAILS = 1088; +const MAX_STORAGE_RESOURCE_UNITS = 294_912; + +function malformed(message: string): never { + throw new MxcError('malformed_request', `invalid policyFailures: ${message}`); +} + +function record( + value: unknown, + required: readonly string[], + optional: readonly string[] = [], +): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value) + || required.some((key) => !Object.hasOwn(value, key)) + || Object.keys(value).some((key) => !required.includes(key) && !optional.includes(key))) { + malformed('expected an object with the documented fields'); + } + return value as Record; +} + +function uint32(value: unknown): number { + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 0xffff_ffff) { + malformed('native codes and versions must be unsigned 32-bit integers'); + } + return value; +} + +function uint64(value: unknown): string { + if (typeof value !== 'string' || !/^[0-9]+$/.test(value)) { + malformed('requestedValue and requiredValue must be unsigned decimal strings'); + } + const significant = value.replace(/^0+/, ''); + if (significant.length > 20 + || (significant.length === 20 && significant > '18446744073709551615')) { + malformed('requestedValue and requiredValue must fit unsigned 64-bit values'); + } + return value; +} + +function resourceUnits(value: unknown): number[] { + if (!Array.isArray(value)) malformed('resource must be an array of UTF-16 code units'); + if (value.length >= 32_768) malformed('per-result resource limit exceeded'); + return Array.from(value, (unit: unknown) => { + if (typeof unit !== 'number' || !Number.isInteger(unit) || unit < 0 || unit > 0xffff) { + malformed('resource must contain unsigned 16-bit integers'); + } + return unit; + }); +} + +function readPolicyFailures(value: unknown): PolicyFailures { + const failures = record(value, ['formatVersion', 'results']); + if (failures.formatVersion !== 1) malformed('unsupported formatVersion'); + if (!Array.isArray(failures.results) || failures.results.length > MAX_STORAGE_RESULTS) { + malformed('results must be an array of at most 65 native batches'); + } + let detailCount = 0; + let resourceCount = 0; + const results = Array.from(failures.results, (entry: unknown): PolicyFailureResult => { + const result = record(entry, ['version', 'minimumReaderVersion', 'outcome', 'details']); + if (!Array.isArray(result.details) || result.details.length > 64) { + malformed('each result must contain an array of at most 64 details'); + } + detailCount += result.details.length; + if (detailCount > MAX_STORAGE_DETAILS) malformed('total detail storage limit exceeded'); + let resultResourceCount = 0; + const details = Array.from(result.details, (entry: unknown): PolicyFailureDetail => { + const detail = record(entry, [ + 'failureClass', 'failureReason', 'requiredAction', 'resourceKind', + 'valueKind', 'flags', 'requestedValue', 'requiredValue', + ], ['resource']); + const resource = detail.resource === undefined || detail.resource === null + ? undefined : resourceUnits(detail.resource); + if (resource !== undefined) resultResourceCount += resource.length + 1; + if (resultResourceCount > 32_768) malformed('per-result resource limit exceeded'); + return { + failureClass: uint32(detail.failureClass), + failureReason: uint32(detail.failureReason), + requiredAction: uint32(detail.requiredAction), + resourceKind: uint32(detail.resourceKind), + valueKind: uint32(detail.valueKind), + flags: uint32(detail.flags), + requestedValue: uint64(detail.requestedValue), + requiredValue: uint64(detail.requiredValue), + ...(resource === undefined ? {} : { resource }), + }; + }); + resourceCount += resultResourceCount; + if (resourceCount > MAX_STORAGE_RESOURCE_UNITS) malformed('total resource storage limit exceeded'); + return { + version: uint32(result.version), + minimumReaderVersion: uint32(result.minimumReaderVersion), + outcome: uint32(result.outcome), + details, + }; + }); + return { formatVersion: 1, results }; +} + +function storageJson(value: PolicyFailures): string { + const json = JSON.stringify(value); + if (Buffer.byteLength(json, 'utf8') > MAX_STORAGE_BYTES) malformed('JSON storage limit exceeded'); + return json; +} + +/** Parse caller-owned storage, preserving unknown codes and raw UTF-16 units. */ +export function parsePolicyFailures(json: string): PolicyFailures { + if (typeof json !== 'string' || Buffer.byteLength(json, 'utf8') > MAX_STORAGE_BYTES) { + malformed('expected a JSON string of at most 4.5 MiB'); + } + let value: unknown; + try { + value = JSON.parse(json); + } catch { + malformed('malformed JSON'); + } + if (hasDuplicateJsonKeys(json)) malformed('duplicate JSON object keys'); + return readPolicyFailures(value); +} + +/** Serialize caller-owned storage. Native preparation enforces stricter input limits. */ +export function serializePolicyFailures(value: PolicyFailures): string { + return storageJson(readPolicyFailures(value)); +} + +/** Merge complete batches in first-seen order into an owned, bounded report. */ +export function mergePolicyFailures(older: PolicyFailures, newer: PolicyFailures): PolicyFailures { + const inputs = [readPolicyFailures(older), readPolicyFailures(newer)]; + const results: PolicyFailureResult[] = []; + const seen = new Set(); + for (const input of inputs) { + storageJson(input); + for (const result of input.results) { + const key = JSON.stringify(result, (name, value: unknown) => ( + (name === 'requestedValue' || name === 'requiredValue') && typeof value === 'string' + ? value.replace(/^0+(?=\d)/, '') : value + )); + if (!seen.has(key)) { + seen.add(key); + results.push(result); + } + } + } + const merged = readPolicyFailures({ formatVersion: 1, results }); + storageJson(merged); + return merged; +} + +/** + * Extract an owned copy from an SDK error. Malformed native details raise + * `backend_error`; `parsePolicyFailures` raises `malformed_request` for + * invalid stored observations. + */ +export function policyFailuresFromError(error: unknown): PolicyFailures | undefined { + if (!(error instanceof MxcError) || error.details?.policyFailures === undefined) return undefined; + try { + const failures = readPolicyFailures(error.details.policyFailures); + storageJson(failures); + return failures; + } catch (cause) { + if (cause instanceof MxcError && cause.code === 'malformed_request') { + throw new MxcError('backend_error', 'native policy failures are malformed'); + } + throw cause; + } +} + +/** + * Decode resource text strictly. This does not establish completeness or safety + * for use as a path or as corrective policy. + */ +export function policyFailureResourceText(detail: PolicyFailureDetail): string | undefined { + if (detail.resource === undefined) return undefined; + const units = resourceUnits(detail.resource); + let text = ''; + for (let index = 0; index < units.length; index += 1) { + const unit = units[index]; + if (unit >= 0xd800 && unit <= 0xdbff) { + const low = units[++index]; + if (low === undefined || low < 0xdc00 || low > 0xdfff) malformed('resource contains malformed UTF-16'); + text += String.fromCharCode(unit, low); + } else { + if (unit >= 0xdc00 && unit <= 0xdfff) malformed('resource contains malformed UTF-16'); + text += String.fromCharCode(unit); + } + } + return text; +} diff --git a/sdk/node/src/v1/dev/policy-preparation.ts b/sdk/node/src/v1/dev/policy-preparation.ts new file mode 100644 index 0000000000..997eaff8a3 --- /dev/null +++ b/sdk/node/src/v1/dev/policy-preparation.ts @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { MxcError } from '../errors.js'; +import { hasDuplicateJsonKeys } from './json-duplicate-keys.js'; + +/** Preparation authorization, independent of creation options and the JSON version. */ +export interface PolicyPreparationOptions { + experimental?: boolean; +} + +/** A JSON value, including real JSON null. Numbers must be finite. */ +export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; + +/** An RFC 6901 JSON Pointer, using ~0 and ~1 to escape ~ and /. */ +export type JsonPointer = string; + +/** An ordered edit; operation distinguishes absence from a real JSON null. */ +export type PolicyPreparationChange = + | { operation: 'add'; path: JsonPointer; before: null; after: JsonValue } + | { operation: 'remove'; path: JsonPointer; before: JsonValue; after: null } + | { operation: 'replace'; path: JsonPointer; before: JsonValue; after: JsonValue }; + +/** A candidate request and its ordered edits, for application inspection and acceptance. */ +export interface PreparedPolicyRequest { + requestJson: string; + changes: PolicyPreparationChange[]; +} + +const MAX_INPUT_BYTES = 8 * 1024 * 1024; +const MAX_RESULT_BYTES = 32 * 1024 * 1024; +const MAX_CHANGES = 4096; + +function malformed(message: string): never { + throw new MxcError('malformed_request', `invalid prepared policy request: ${message}`); +} + +function validText(value: string): boolean { + return Buffer.from(value, 'utf8').toString('utf8') === value; +} + +function checkRequestJson(requestJson: unknown): asserts requestJson is string { + if (typeof requestJson !== 'string' || requestJson.includes('\0') || !validText(requestJson)) { + malformed('requestJson must contain valid UTF-8 JSON text'); + } + let request: unknown; + try { + request = JSON.parse(requestJson); + } catch { + malformed('requestJson must contain a JSON object'); + } + if (request === null || typeof request !== 'object' || Array.isArray(request)) { + malformed('requestJson must contain a JSON object'); + } +} + +/** @internal Validate transport text without selecting a contract, phase, or backend. */ +export function checkPolicyPreparationRequest(requestJson: unknown): asserts requestJson is string { + if (typeof requestJson !== 'string' || Buffer.byteLength(requestJson, 'utf8') > MAX_INPUT_BYTES) { + malformed('input requestJson must be a JSON string of at most 8 MiB UTF-8'); + } + checkRequestJson(requestJson); +} + +function record(value: unknown, fields: readonly string[]): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value) + || fields.some((key) => !Object.hasOwn(value, key)) + || Object.keys(value).some((key) => !fields.includes(key))) { + malformed('expected an object with the documented fields'); + } + return value as Record; +} + +function copyJsonValue(value: unknown, ancestors = new Set()): JsonValue { + if (value === null || typeof value === 'boolean') return value; + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && validText(value)) return value; + if (typeof value !== 'object' || value === null + || (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype + && Object.getPrototypeOf(value) !== null) + || ancestors.has(value)) { + malformed('before and after must be JSON values'); + } + ancestors.add(value); + try { + if (Array.isArray(value)) { + return Array.from(value, (entry: unknown) => copyJsonValue(entry, ancestors)); + } + return Object.fromEntries(Object.entries(value).map(([key, entry]) => { + if (!validText(key)) malformed('JSON object keys must contain valid Unicode'); + return [key, copyJsonValue(entry, ancestors)]; + })); + } finally { + ancestors.delete(value); + } +} + +function readPreparedPolicyRequest(value: unknown): PreparedPolicyRequest { + const result = record(value, ['requestJson', 'changes']); + checkRequestJson(result.requestJson); + if (!Array.isArray(result.changes) || result.changes.length > MAX_CHANGES) { + malformed('changes must be an array of at most 4096 edits'); + } + const changes = Array.from(result.changes, (entry: unknown): PolicyPreparationChange => { + const change = record(entry, ['operation', 'path', 'before', 'after']); + if (typeof change.path !== 'string' || !validText(change.path) + || (change.path !== '' && !change.path.startsWith('/')) + || /~(?:[^01]|$)/.test(change.path)) { + malformed('path must be an RFC 6901 JSON Pointer'); + } + const before = copyJsonValue(change.before); + const after = copyJsonValue(change.after); + switch (change.operation) { + case 'add': + if (before !== null) malformed('add requires before: null for absence'); + return { operation: 'add', path: change.path, before, after }; + case 'remove': + if (after !== null) malformed('remove requires after: null for absence'); + return { operation: 'remove', path: change.path, before, after }; + case 'replace': + return { operation: 'replace', path: change.path, before, after }; + default: + malformed('operation must be add, remove, or replace'); + } + }); + return { requestJson: result.requestJson, changes }; +} + +/** Parse application-owned preparation storage without echoing malformed input. */ +export function parsePreparedPolicyRequest(json: string): PreparedPolicyRequest { + if (typeof json !== 'string' || Buffer.byteLength(json, 'utf8') > MAX_RESULT_BYTES + || !validText(json)) { + malformed('expected a JSON string of at most 32 MiB UTF-8'); + } + try { + const value = JSON.parse(json) as unknown; + if (hasDuplicateJsonKeys(json)) malformed('duplicate JSON object keys'); + return readPreparedPolicyRequest(value); + } catch (error) { + if (error instanceof MxcError) throw error; + malformed('malformed JSON or invalid JSON values'); + } +} + +/** Serialize an owned, checked candidate; both before and after remain present. */ +export function serializePreparedPolicyRequest(value: PreparedPolicyRequest): string { + try { + const json = JSON.stringify(readPreparedPolicyRequest(value)); + if (Buffer.byteLength(json, 'utf8') > MAX_RESULT_BYTES) malformed('JSON result limit exceeded'); + return json; + } catch (error) { + if (error instanceof MxcError) throw error; + malformed('invalid JSON values'); + } +} diff --git a/sdk/node/src/v1/errors.ts b/sdk/node/src/v1/errors.ts index 629a730b85..728d3cb5cc 100644 --- a/sdk/node/src/v1/errors.ts +++ b/sdk/node/src/v1/errors.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import { inspect } from 'node:util'; + /** * Closed set of MXC wire-format error codes. Mirrors `MxcErrorCode` on the * Rust side one-for-one and serialises as the same snake_case strings on @@ -56,6 +58,10 @@ export interface MxcErrorFields { /** * Open extension point for backend-specific structured data that has no * dedicated field. Named fields are reserved for backend-neutral concepts. + * + * `details` is not shown by console inspection, but `JSON.stringify` and + * object copies include it. It can carry policy resource paths, so filter + * it before logging or serializing an error. */ details?: Record; } @@ -81,7 +87,8 @@ export class MxcError extends Error { readonly operation?: string; readonly nativeCode?: string; readonly remediation?: string; - readonly details?: Record; + /** Backend-specific structured data. JSON serialization and copies include it; filter before logging. */ + declare readonly details?: Record; /** Canonical form: pass the full field set as one object. */ constructor(fields: MxcErrorFields); @@ -114,7 +121,16 @@ export class MxcError extends Error { this.operation = fields.operation; this.nativeCode = fields.nativeCode; this.remediation = fields.remediation; - this.details = fields.details; + // An enumerable accessor keeps `details` in copies and JSON, while Node's + // uncaught-error printer shows only `[Getter/Setter]` unless the + // application enables `inspect.defaultOptions.getters`. + let detailsValue = fields.details; + Object.defineProperty(this, 'details', { + configurable: true, + enumerable: true, + get: () => detailsValue, + set: (value: Record | undefined) => { detailsValue = value; }, + }); // Restore the prototype chain so `instanceof MxcError` keeps working // after the TypeScript ES2020 → ES5-compatible class downlevelling. Object.setPrototypeOf(this, new.target.prototype); @@ -122,6 +138,18 @@ export class MxcError extends Error { } } +Object.defineProperty(MxcError.prototype, inspect.custom, { + configurable: true, + writable: true, + value(this: MxcError): string { + const context = [this.code, this.operation, this.nativeCode] + .filter((value) => value !== undefined).join(' '); + const header = `${this.name}: ${this.message} [${context}]`; + const frames = this.stack?.split('\n').slice(1).join('\n'); + return frames ? `${header}\n${frames}` : header; + }, +}); + /** * Constructs an `MxcError` from a wire-format error code. Accepts a plain * `string` so callers parsing a wire envelope don't need to narrow first; diff --git a/sdk/node/tests/integration/dev-api.ts b/sdk/node/tests/integration/dev-api.ts index fe193263b0..c698a4425f 100644 --- a/sdk/node/tests/integration/dev-api.ts +++ b/sdk/node/tests/integration/dev-api.ts @@ -3,21 +3,62 @@ import { runJson, + spawnJson, spawnWithPtyJson, provisionContainerJson, type JsonOptions, type PtyJsonOptions, + type PolicyPreparationOptions, + type PreparedPolicyRequest, + type PolicyFailures, + parsePolicyFailures, + serializePolicyFailures, + mergePolicyFailures, + preparePolicyJson, + parsePreparedPolicyRequest, + serializePreparedPolicyRequest, + policyFailuresFromError, + policyFailureResourceText, } from '@microsoft/mxc-sdk/v1/dev'; -import type { ExecutionResult, MxcPtyProcess } from '@microsoft/mxc-sdk/v1'; +import type { ExecutionResult, MxcProcess, MxcPtyProcess } from '@microsoft/mxc-sdk/v1'; export function verifyPackedDevSignatures( json: string, options: JsonOptions, ptyOptions: PtyJsonOptions, -): [Promise, Promise, Promise] { +): [Promise, Promise, Promise, Promise] { return [ runJson(json, options), + spawnJson(json, options), spawnWithPtyJson(json, ptyOptions), provisionContainerJson(json, options), ]; } + +export function verifyPackedPolicyPreparationSignatures( + json: string, + failures: PolicyFailures, + error: unknown, + options: PolicyPreparationOptions, +): [Promise, PolicyFailures | undefined, string | undefined] { + const restored = parsePolicyFailures(serializePolicyFailures(failures)); + const observed = policyFailuresFromError(error); + const merged = observed === undefined ? restored : mergePolicyFailures(restored, observed); + const detail = restored.results[0]?.details[0]; + return [ + preparePolicyJson(json, merged, options), + observed, + detail === undefined ? undefined : policyFailureResourceText(detail), + ]; +} + +export async function prepareAndRunIfAccepted( + requestJson: string, + failures: PolicyFailures, + accept: (candidate: PreparedPolicyRequest) => boolean | Promise, +): Promise { + const prepared = await preparePolicyJson(requestJson, failures); + const candidate = parsePreparedPolicyRequest(serializePreparedPolicyRequest(prepared)); + if (!await accept(candidate)) return undefined; + return runJson(candidate.requestJson); +} diff --git a/sdk/node/tests/integration/dev.test.ts b/sdk/node/tests/integration/dev.test.ts index 8fce15dd71..7970a941fc 100644 --- a/sdk/node/tests/integration/dev.test.ts +++ b/sdk/node/tests/integration/dev.test.ts @@ -2,16 +2,23 @@ // Licensed under the MIT License. import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; import { it } from 'node:test'; -import { runJson, spawnJson, spawnWithPtyJson, validateProcessJson } from '@microsoft/mxc-sdk/v1/dev'; -import { MxcError } from '@microsoft/mxc-sdk/v1'; -import { isLinuxRoot, sandboxSkipReason } from './test-helpers.js'; +import { + runJson, spawnJson, spawnWithPtyJson, validateProcessJson, + preparePolicyJson, parsePolicyFailures, serializePolicyFailures, type PolicyFailures, + PolicyFailureOutcomes, PolicyFailureClasses, PolicyFailureReasons, PolicyFailureActions, + PolicyFailureResourceKinds, PolicyFailureValueKinds, PolicyFailureAccessValues, PolicyFailureFlags, + policyFailureResourceText, +} from '@microsoft/mxc-sdk/v1/dev'; +import { MxcError, policy } from '@microsoft/mxc-sdk/v1'; +import { createPrivateWindowsTempDir, isLinuxRoot, sandboxSkipReason } from './test-helpers.js'; const windows = process.platform === 'win32'; const creationSkip = windows ? sandboxSkipReason : process.platform === 'linux' ? (isLinuxRoot ? undefined : 'Bubblewrap requires root') : process.platform === 'darwin' ? undefined : 'Unsupported host'; - async function readAll(stream: NodeJS.ReadableStream): Promise { let text = ''; for await (const chunk of stream) text += chunk.toString(); @@ -98,3 +105,184 @@ it('V1.Dev rejects unknown fields and unregistered exact versions through native (error: unknown) => error instanceof MxcError && error.code === 'malformed_request', ); }); + +it('V1.Dev bounds terminal policy failures at native preparation without creating a workload', { + skip: !windows, +}, async () => { + const report: PolicyFailures = { + formatVersion: 1, + results: Array.from({ length: 65 }, (_, outcome) => ({ + version: 1, minimumReaderVersion: 1, outcome, details: [], + })), + }; + const restored = parsePolicyFailures(serializePolicyFailures(report)); + assert.deepEqual(restored, report); + const request = '{"version":"1.1.0-alpha","containment":"processcontainer",' + + '"process":{"commandLine":"echo must-not-run"}}'; + await assert.rejects(preparePolicyJson(request, restored), (error: unknown) => { + assert.ok(error instanceof MxcError); + assert.equal(error.code, 'malformed_request'); + assert.equal(error.details?.ffiStatus, 1); + assert.match(error.message, /result limit/); + return true; + }); +}); + +it('V1.Dev prepares filesystem ceilings for caller-accepted strict creation', { + skip: windows ? sandboxSkipReason : 'Windows ProcessContainer only', + timeout: 90000, +}, async () => { + const directory = createPrivateWindowsTempDir('mxc-policy-preparation', import.meta.dirname); + try { + const toolDirectory = path.join(directory, 'tools'); + const restrictedDirectory = path.join(directory, 'restricted-😀'); + const permittedDirectory = path.join(directory, 'permitted'); + for (const child of [toolDirectory, restrictedDirectory, permittedDirectory]) fs.mkdirSync(child); + const workload = path.join(toolDirectory, 'workload.cmd'); + const toolFile = path.join(toolDirectory, 'tool-data.txt'); + const restrictedFile = path.join(restrictedDirectory, 'seed.txt'); + const permittedFile = path.join(permittedDirectory, 'sentinel.txt'); + const seed = 'RESTRICTED_SEED_UNCHANGED'; + const permittedSeed = 'PERMITTED_SEED'; + fs.writeFileSync(path.join(toolDirectory, 'pwsh.exe'), ''); + fs.writeFileSync(toolFile, 'PREPARATION_TOOL_READ_OK\r\n'); + fs.writeFileSync(restrictedFile, seed); + fs.writeFileSync(permittedFile, permittedSeed); + fs.writeFileSync(workload, [ + '@echo off', + 'setlocal EnableDelayedExpansion', + 'echo PREPARATION_READY', + 'set "INPUT="', + 'set /p INPUT=', + 'set "MODE=run"', + 'if defined INPUT set "MODE=spawn"', + '(echo UNEXPECTED_RESTRICTED_WRITE) 2>nul >"%~1"', + 'rem Redirection denial may leave ERRORLEVEL unchanged; verify the seed instead.', + 'set "SEED="', + 'set /p SEED=<"%~1"', + `if not "!SEED!"=="${seed}" exit /b 21`, + 'ver >nul', + '>"%~2" echo PERMITTED_WRITE:!MODE!', + 'if errorlevel 1 exit /b 22', + 'ver >nul', + 'type "%~3"', + 'if errorlevel 1 exit /b 23', + 'if /i not "%CD%"=="%~4" exit /b 24', + 'echo PREPARATION_CWD_MATCHED', + 'echo PREPARATION_INPUT:!INPUT!', + 'echo PREPARATION_SEED:!SEED!', + 'echo PREPARATION_ENV:%MXC_PREPARATION_VALUE%', + 'echo PREPARATION_STDERR:!MODE! 1>&2', + 'exit /b 0', + ].join('\r\n') + '\r\n'); + const tools = policy.filesystem.getAvailableToolsPolicy({ + PATH: toolDirectory, + SystemDrive: path.parse(directory).root.replace(/\\$/, ''), + }, { containerType: 'processcontainer' }); + assert.deepEqual(tools, { readonlyPaths: [toolDirectory], readwritePaths: [] }); + const request = { + version: '1.1.0-alpha', + containment: 'processcontainer', + filesystem: { ...tools, readwritePaths: [restrictedDirectory, permittedDirectory] }, + ui: { disable: true }, + process: { + commandLine: `cmd.exe /d /v:on /c ""${workload}" "${restrictedFile}" "${permittedFile}" "${toolFile}" "${restrictedDirectory}""`, + timeout: 30000, + inheritDefaultEnv: true, + env: ['MXC_PREPARATION_VALUE=ORIGINAL_VALUE'], + }, + }; + const failures: PolicyFailures = { + formatVersion: 1, + results: [{ + version: 1, + minimumReaderVersion: 1, + outcome: PolicyFailureOutcomes.Blocked, + details: [{ + failureClass: PolicyFailureClasses.Filesystem, + failureReason: PolicyFailureReasons.AccessExceedsCeiling, + requiredAction: PolicyFailureActions.RestrictFilesystemAccess, + resourceKind: PolicyFailureResourceKinds.Path, + valueKind: PolicyFailureValueKinds.Access, + flags: PolicyFailureFlags.HasActionableDetails | PolicyFailureFlags.ResourceComplete, + requestedValue: PolicyFailureAccessValues.ReadWrite, + requiredValue: PolicyFailureAccessValues.ReadOnly, + resource: Array.from({ length: restrictedDirectory.length }, (_, index) => + restrictedDirectory.charCodeAt(index)), + }], + }], + }; + const requestJson = JSON.stringify(request); + const storedFailures = serializePolicyFailures(failures); + const restoredFailures = parsePolicyFailures(storedFailures); + assert.deepEqual(restoredFailures, failures); + assert.equal(policyFailureResourceText(restoredFailures.results[0].details[0]), restrictedDirectory); + const baseline = await runJson(requestJson); + assert.equal(baseline.exitCode, 21, baseline.stderr); + assert.match(fs.readFileSync(restrictedFile, 'utf8'), /UNEXPECTED_RESTRICTED_WRITE/); + fs.writeFileSync(restrictedFile, seed); + const prepared = await preparePolicyJson(requestJson, restoredFailures); + assert.equal(JSON.stringify(request), requestJson); + assert.equal(serializePolicyFailures(restoredFailures), storedFailures); + assert.deepEqual(restoredFailures, failures); + assert.equal(fs.readFileSync(restrictedFile, 'utf8'), seed); + assert.equal(fs.readFileSync(permittedFile, 'utf8'), permittedSeed); + assert.deepEqual([...prepared.changes].sort((left, right) => left.path.localeCompare(right.path)), [ + { + operation: 'replace', path: '/filesystem/readonlyPaths', + before: [toolDirectory], after: [toolDirectory, restrictedDirectory], + }, + { + operation: 'replace', path: '/filesystem/readwritePaths', + before: [restrictedDirectory, permittedDirectory], after: [permittedDirectory], + }, + { operation: 'add', path: '/process/cwd', before: null, after: restrictedDirectory }, + ]); + assert.deepEqual(JSON.parse(prepared.requestJson), { + ...request, + filesystem: { readonlyPaths: [toolDirectory, restrictedDirectory], readwritePaths: [permittedDirectory] }, + process: { ...request.process, cwd: restrictedDirectory }, + }); + // The caller accepts this inspected candidate; creation receives no observations. + const acceptedRequestJson = prepared.requestJson; + const assertOutput = (stdout: string, stderr: string, input: string, mode: string) => { + const lines = stdout.split(/\r?\n/).map((line) => line.trim()); + assert.ok(lines.includes('PREPARATION_READY'), stdout); + assert.ok(lines.includes('PREPARATION_TOOL_READ_OK'), stdout); + assert.ok(lines.includes('PREPARATION_CWD_MATCHED'), stdout); + assert.ok(lines.includes(`PREPARATION_INPUT:${input.trim()}`), stdout); + assert.ok(lines.includes(`PREPARATION_SEED:${seed}`), stdout); + assert.ok(lines.includes('PREPARATION_ENV:ORIGINAL_VALUE'), stdout); + assert.ok(stderr.includes(`PREPARATION_STDERR:${mode}`), stderr); + assert.equal(fs.readFileSync(restrictedFile, 'utf8'), seed); + assert.equal(fs.readFileSync(permittedFile, 'utf8').trim(), `PERMITTED_WRITE:${mode}`); + }; + const captured = await runJson(acceptedRequestJson); + assert.equal(captured.exitCode, 0, captured.stderr); + assert.equal(captured.timedOut, false); + assertOutput(captured.stdout, captured.stderr, '', 'run'); + fs.writeFileSync(permittedFile, permittedSeed); + const spawned = await spawnJson(acceptedRequestJson); + try { + assert.ok(spawned.standardInput); + assert.ok(spawned.standardOutput); + assert.ok(spawned.standardError); + const stdout = readAll(spawned.standardOutput); + const stderr = readAll(spawned.standardError); + const input = 'PREPARED_STDIN\r\n'; + spawned.standardInput.end(input); + const outcome = await spawned.wait(); + const output = await stdout; + const errors = await stderr; + assert.equal(outcome.exitCode, 0, errors); + assert.equal(outcome.timedOut, false); + assertOutput(output, errors, input, 'spawn'); + } finally { + spawned.dispose(); + } + assert.equal(JSON.stringify(request), requestJson); + assert.equal(serializePolicyFailures(restoredFailures), storedFailures); + } finally { + fs.rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +}); diff --git a/sdk/node/tests/integration/test-helpers.ts b/sdk/node/tests/integration/test-helpers.ts index 76a967485a..0153980c52 100644 --- a/sdk/node/tests/integration/test-helpers.ts +++ b/sdk/node/tests/integration/test-helpers.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { spawn, ChildProcess, execSync } from 'child_process'; +import { spawn, ChildProcess, execSync, execFileSync } from 'child_process'; import assert from 'node:assert'; import type { TestContext } from 'node:test'; import path from 'path'; @@ -409,6 +409,31 @@ export function createTempDir(prefix: string = 'mxc-test', parentDirectory: stri return dir; } +/** Keep Windows fixtures private even when their parent grants AppContainer access. */ +export function createPrivateWindowsTempDir(prefix: string, parentDirectory: string): string { + const directory = createTempDir(prefix, parentDirectory); + try { + const systemDirectory = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32'); + execFileSync(path.join(systemDirectory, 'WindowsPowerShell', 'v1.0', 'powershell.exe'), [ + '-NoProfile', '-NonInteractive', '-Command', + "$ErrorActionPreference = 'Stop'; " + + '$sid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value; ' + + '$acl = [Security.AccessControl.DirectorySecurity]::new(); ' + + '$acl.SetSecurityDescriptorSddlForm("D:P(A;OICI;FA;;;$sid)", ' + + '[Security.AccessControl.AccessControlSections]::Access); ' + + '[IO.Directory]::SetAccessControl($env:MXC_PRIVATE_FIXTURE, $acl)', + ], { + env: { ...process.env, MXC_PRIVATE_FIXTURE: directory }, + windowsHide: true, + timeout: 30000, + }); + return directory; + } catch (error) { + fs.rmSync(directory, { recursive: true, force: true }); + throw error; + } +} + // Python helpers /** Detect a usable Python command. Returns undefined if not installed. */ diff --git a/sdk/node/tests/integration/windows-process-container.test.ts b/sdk/node/tests/integration/windows-process-container.test.ts index 63d942d272..24a8099a83 100644 --- a/sdk/node/tests/integration/windows-process-container.test.ts +++ b/sdk/node/tests/integration/windows-process-container.test.ts @@ -3,7 +3,7 @@ import { describe, it, before, after, afterEach } from 'node:test'; import assert from 'node:assert'; -import { ChildProcess, execFileSync } from 'child_process'; +import { ChildProcess } from 'child_process'; import { EventEmitter } from 'events'; import fs from 'fs'; import os from 'os'; @@ -13,6 +13,7 @@ import { supportedVersions, sandboxSkipReason, createTempDir, + createPrivateWindowsTempDir, withToolPaths, startTestProxy, pythonCommand, @@ -186,22 +187,8 @@ describe(`Windows Process Container (schema ${schemaVersion})`, { }); it('public tool discovery allows tool reads but denies sibling private files', { skip: sandboxSkipReason }, async () => { - tempDir = createTempDir('mxc-tool-discovery', import.meta.dirname); + tempDir = createPrivateWindowsTempDir('mxc-tool-discovery', import.meta.dirname); const systemDirectory = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32'); - // Keep the new fixture private even when the host temp directory grants AppContainer access. - execFileSync(path.join(systemDirectory, 'WindowsPowerShell', 'v1.0', 'powershell.exe'), [ - '-NoProfile', '-NonInteractive', '-Command', - "$ErrorActionPreference = 'Stop'; " + - '$sid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value; ' + - '$acl = [Security.AccessControl.DirectorySecurity]::new(); ' + - '$acl.SetSecurityDescriptorSddlForm("D:P(A;OICI;FA;;;$sid)", ' + - '[Security.AccessControl.AccessControlSections]::Access); ' + - '[IO.Directory]::SetAccessControl($env:MXC_TOOL_DISCOVERY_FIXTURE, $acl)', - ], { - env: { ...process.env, MXC_TOOL_DISCOVERY_FIXTURE: tempDir }, - windowsHide: true, - timeout: 30000, - }); const toolDirectory = path.join(tempDir, 'tools'); const privateDirectory = path.join(tempDir, 'private'); diff --git a/sdk/node/tests/unit/binding-run.test.ts b/sdk/node/tests/unit/binding-run.test.ts index 3d1461b902..d4cd9c2f4d 100644 --- a/sdk/node/tests/unit/binding-run.test.ts +++ b/sdk/node/tests/unit/binding-run.test.ts @@ -2,12 +2,55 @@ // Licensed under the MIT License. import assert from 'node:assert'; -import { describe, it } from 'node:test'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import * as path from 'node:path'; +import { describe, it, type TestContext } from 'node:test'; +import koffi from 'koffi'; import { _errorCodeForNativeStatus } from '../../src/bindings/run.js'; -import { parseExecutionMetadata } from '../../src/bindings/native-error.js'; +import { + AbiErrorDetailType, + createAbiErrorDetail, + parseExecutionMetadata, + type AbiErrorDetail, +} from '../../src/bindings/native-error.js'; import { MxcError } from '../../src/v1/errors.js'; +import { run } from '../../src/v1/container.js'; +import { + runJson, preparePolicyJson, policyFailuresFromError, serializePolicyFailures, + type PolicyFailures, type PreparedPolicyRequest, +} from '../../src/v1/dev/index.js'; +import { getStreamingNative } from '../../src/bindings/streaming.js'; import type { CaptureDenialsResult, CaptureDenialsError, ExecutionMetadata } from '../../src/v1/index.js'; +function mockNativeLibrary( + t: TestContext, + bind: (symbol: string, result: unknown, parameters?: unknown[]) => unknown, +) { + const directory = mkdtempSync('.mxc-node-binding-'); + const filename = process.platform === 'win32' ? 'mxc_ffi.dll' + : process.platform === 'darwin' ? 'libmxc_ffi.dylib' : 'libmxc_ffi.so'; + const oldFfiDir = process.env.MXC_FFI_DIR; + writeFileSync(path.join(directory, filename), ''); + process.env.MXC_FFI_DIR = path.resolve(directory); + let unloadCount = 0; + const load = t.mock.method(koffi, 'load', () => ({ + func(symbol: string, result: unknown, parameters?: unknown[]) { + if (symbol === 'const char *mxc_version(void)') return () => 'unit-fixture'; + return bind(symbol, result, parameters); + }, + unload() { unloadCount += 1; }, + })); + return { + get unloadCount() { return unloadCount; }, + dispose() { + load.mock.restore(); + if (oldFfiDir === undefined) delete process.env.MXC_FFI_DIR; + else process.env.MXC_FFI_DIR = oldFfiDir; + rmSync(directory, { recursive: true, force: true }); + }, + }; +} + describe('native execution metadata decoding', () => { const captureDenials: CaptureDenialsResult = { type: 'captureDenials', @@ -57,6 +100,305 @@ describe('native execution metadata decoding', () => { }); describe('native run binding', () => { + it('keeps Dev creation strict and frees owned error JSON through its result exactly once', async (t) => { + const report = { formatVersion: 1, results: [] }; + const detailsJson = Buffer.from(JSON.stringify({ + policyFailures: report, backendDetail: { retained: true }, + }) + '\0'); + interface Result { + status: number; + exitCode: number; + timedOut: number; + stdout: unknown; + stderr: unknown; + error: AbiErrorDetail; + outputMetadata: unknown; + warnings: unknown; + } + const calls: Array<{ symbol: string; args: unknown[] }> = []; + let freed = 0; + const fixture = mockNativeLibrary(t, (symbol, _result, parameters) => { + if (symbol === 'mxc_run_result_free') { + return (result: Result) => { + freed += 1; + Object.assign(result.error, createAbiErrorDetail()); + }; + } + assert.strictEqual(symbol, 'mxc_run_json'); + assert.strictEqual(parameters?.length, 3); + const nativeRun = (...args: unknown[]) => { + assert.strictEqual(args.length, 3); + const result = args[2] as Result; + assert.deepStrictEqual(result.error, createAbiErrorDetail()); + calls.push({ symbol, args: args.slice(0, -1) }); + const status = calls.length === 1 ? 11 : 0; + Object.assign(result, { + status, exitCode: status ? -1 : 7, timedOut: 0, + stdout: null, stderr: null, outputMetadata: null, + warnings: status ? null : Buffer.from('["ordinary warning"]\0'), + error: status ? { + message: Buffer.from('creation blocked\0'), + operation: Buffer.from('CreateProcess\0'), + nativeCode: Buffer.from('0x80070005\0'), + remediation: Buffer.from('Inspect the structured result.\0'), + detailsJson, + } : createAbiErrorDetail(), + }); + return status; + }; + return Object.assign(nativeRun, { + async(...args: unknown[]) { + const completion = args.pop() as (error: Error | null, status: number) => void; + queueMicrotask(() => completion(null, nativeRun(...args))); + }, + }); + }); + try { + const pointerSize = koffi.sizeof('void *'); + assert.strictEqual(koffi.offsetof(AbiErrorDetailType, 'detailsJson'), 4 * pointerSize); + assert.strictEqual(koffi.sizeof(AbiErrorDetailType), 5 * pointerSize); + const json = ' { "version":"1.1.0-alpha","process":{"commandLine":"echo"} } '; + let refusal: unknown; + await assert.rejects(runJson(json), (error: unknown) => { + refusal = error; + assert.ok(error instanceof MxcError); + assert.strictEqual(error.code, 'policy_validation'); + assert.strictEqual(error.message, 'creation blocked'); + assert.strictEqual(error.operation, 'CreateProcess'); + assert.strictEqual(error.nativeCode, '0x80070005'); + assert.strictEqual(error.remediation, 'Inspect the structured result.'); + assert.deepStrictEqual(error.details, { + ffiStatus: 11, policyFailures: report, backendDetail: { retained: true }, + }); + return true; + }); + assert.strictEqual(calls.length, 1); + assert.strictEqual(freed, 1); + const failures = policyFailuresFromError(refusal); + assert.deepStrictEqual(failures, report, 'decoded policy failures survive the native result free'); + const output = await runJson(json, { experimental: true }); + assert.deepStrictEqual(output, { + stdout: '', stderr: '', exitCode: 7, timedOut: false, warnings: ['ordinary warning'], + }); + await run({ command: 'echo', containerName: 'strict-native-unit' }); + assert.deepStrictEqual(calls.slice(0, 2), [ + { symbol: 'mxc_run_json', args: [json, 0] }, + { symbol: 'mxc_run_json', args: [json, 1] }, + ]); + assert.strictEqual(calls[2].symbol, 'mxc_run_json'); + assert.strictEqual(calls[2].args.length, 2); + assert.strictEqual(calls[2].args[1], 0); + assert.strictEqual(freed, 3); + assert.strictEqual(fixture.unloadCount, 3); + } finally { + fixture.dispose(); + } + }); + + it('prepares through Koffi async with separate inputs and owned output/error allocations', async (t) => { + const requestJson = ' { "version":"1.1.0-alpha","process":{"commandLine":"echo"} } '; + const failures: PolicyFailures = { + formatVersion: 1, + results: [{ + version: 0xffff_ffff, minimumReaderVersion: 1, outcome: 0xffff_ffff, + details: [{ + failureClass: 2, failureReason: 2, requiredAction: 2, resourceKind: 1, + valueKind: 1, flags: 0xffff_ffff, + requestedValue: '18446744073709551615', requiredValue: '9007199254740993', + resource: [0xd800, 0], + }], + }], + }; + const candidate: PreparedPolicyRequest = { + requestJson: '{"version":"1.1.0-alpha","filesystem":{"deniedPaths":["C:\\\\private"]},' + + '"process":{"commandLine":"echo"}}', + changes: [{ operation: 'add', path: '/filesystem', before: null, after: { + deniedPaths: ['C:\\private'], + } }], + }; + const payload = Buffer.from(JSON.stringify(candidate) + '\0'); + const diagnostic = Buffer.from('{"private":"SUCCESS_DIAGNOSTIC"}\0'); + const calls: unknown[][] = []; + const freedStrings: unknown[] = []; + const freedErrors: AbiErrorDetail[] = []; + let complete: (() => void) | undefined; + const fixture = mockNativeLibrary(t, (symbol, result, parameters) => { + if (symbol === 'mxc_string_free') { + assert.deepStrictEqual(parameters, ['void *']); + return (pointer: unknown) => { + assert.strictEqual(fixture.unloadCount, 0); + assert.strictEqual(pointer, payload); + freedStrings.push(pointer); + payload.fill(0); + }; + } + if (symbol === 'mxc_error_detail_free') { + return (error: AbiErrorDetail) => { + assert.strictEqual(fixture.unloadCount, 0); + freedErrors.push({ ...error }); + diagnostic.fill(0); + Object.assign(error, createAbiErrorDetail()); + }; + } + assert.strictEqual(symbol, 'mxc_prepare_policy_json'); + assert.strictEqual(result, 'int32_t'); + assert.strictEqual(parameters?.length, 5); + assert.deepStrictEqual(parameters?.slice(0, 3), ['const char *', 'const char *', 'int32_t']); + assert.strictEqual(koffi.sizeof(parameters![3] as string), koffi.sizeof('void *')); + return Object.assign(() => assert.fail('preparation must not call the synchronous binding'), { + async(...args: unknown[]) { + assert.strictEqual(args.length, 6); + calls.push(args.slice(0, 3)); + const out = args[3] as unknown[]; + const error = args[4] as AbiErrorDetail; + const completion = args[5] as (failure: Error | null, status: number) => void; + assert.strictEqual(out.length, 1); + assert.strictEqual(out[0], null); + assert.deepStrictEqual(error, createAbiErrorDetail()); + complete = () => { + out[0] = payload; + error.detailsJson = diagnostic; + completion(null, 0); + }; + }, + }); + }); + try { + const before = JSON.stringify(failures); + let settled = false; + const pending = preparePolicyJson(requestJson, failures, { experimental: true }) + .then((value) => { settled = true; return value; }); + await new Promise((resolve) => setImmediate(resolve)); + assert.strictEqual(settled, false, 'the event loop remains available while native work is pending'); + assert.deepStrictEqual(calls, [[requestJson, serializePolicyFailures(failures), 1]]); + assert.strictEqual(freedErrors.length, 0); + assert.strictEqual(freedStrings.length, 0); + assert.strictEqual(fixture.unloadCount, 0); + assert.ok(complete); + complete(); + assert.deepStrictEqual(await pending, candidate, 'copied JSON survives native frees'); + assert.strictEqual(JSON.stringify(failures), before); + assert.strictEqual(freedStrings.length, 1); + assert.strictEqual(freedErrors.length, 1); + assert.strictEqual(freedErrors[0].detailsJson, diagnostic); + assert.strictEqual(fixture.unloadCount, 1); + } finally { + fixture.dispose(); + } + }); + + it('cleans preparation allocations on native, decoding, callback and scheduling failures', async (t) => { + const marker = 'PREPARATION_MALFORMED_RESULT_PRIVATE'; + const failures: PolicyFailures = { formatVersion: 1, results: [] }; + let mode = ''; + let output: Buffer | null = null; + let diagnostic: Buffer | null = null; + const freeOrder: string[] = []; + const fixture = mockNativeLibrary(t, (symbol) => { + if (symbol === 'mxc_string_free') { + return (pointer: unknown) => { + assert.strictEqual(pointer, output); + freeOrder.push('string'); + output?.fill(0); + }; + } + if (symbol === 'mxc_error_detail_free') { + return (error: AbiErrorDetail) => { + assert.strictEqual(error.detailsJson, diagnostic); + freeOrder.push('error'); + diagnostic?.fill(0); + Object.assign(error, createAbiErrorDetail()); + }; + } + assert.strictEqual(symbol, 'mxc_prepare_policy_json'); + if (mode === 'missing-symbol') throw new Error(`missing symbol: ${marker}`); + return Object.assign(() => assert.fail('synchronous preparation must not run'), { + async( + _request: string, _policyFailures: string, experimental: number, out: unknown[], + error: AbiErrorDetail, completion: (failure: Error | null, status: number) => void, + ) { + assert.strictEqual(experimental, 0); + output = mode === 'no-output' ? null : Buffer.from((mode === 'oversized-result' + ? ' '.repeat(32 * 1024 * 1024 + 1) : `{${marker}`) + '\0'); + diagnostic = Buffer.from((mode === 'malformed-error' ? `{${marker}` + : JSON.stringify({ policyFailures: failures, otherDetail: 'retained' })) + '\0'); + out[0] = output; + Object.assign(error, { + message: Buffer.from('Policy preparation refused.\0'), + operation: Buffer.from('prepare_policy_json\0'), + nativeCode: Buffer.from('0x800704EC\0'), + detailsJson: diagnostic, + }); + if (mode === 'scheduling') throw new Error(marker); + queueMicrotask(() => completion(mode === 'callback' ? new Error(marker) : null, + mode === 'native' || mode === 'malformed-error' ? 11 : 0)); + }, + }); + }); + try { + for (mode of [ + 'native', 'malformed-error', 'no-output', 'malformed-result', 'oversized-result', + 'callback', 'scheduling', 'missing-symbol', + ]) { + freeOrder.length = 0; + output = null; + diagnostic = null; + const priorUnloads = fixture.unloadCount; + await assert.rejects(preparePolicyJson('{}', failures), (error: unknown) => { + assert.ok(error instanceof MxcError); + assert.strictEqual(error.code, mode === 'native' ? 'policy_validation' : 'backend_error'); + assert.strictEqual([error.message, error.stack, String(error)].join('\n').includes(marker), false); + if (mode === 'native') { + assert.strictEqual(error.operation, 'prepare_policy_json'); + assert.strictEqual(error.nativeCode, '0x800704EC'); + assert.deepStrictEqual(error.details, { + policyFailures: failures, otherDetail: 'retained', ffiStatus: 11, + }); + } + return true; + }); + assert.deepStrictEqual(freeOrder, mode === 'missing-symbol' ? [] + : mode === 'no-output' ? ['error'] : ['string', 'error']); + assert.strictEqual(fixture.unloadCount, priorUnloads + 1); + } + } finally { + fixture.dispose(); + } + }); + + it('binds strict Dev spawn without requiring an execution-time policy-failure symbol', async (t) => { + const symbols: string[] = []; + const calls: unknown[][] = []; + const fixture = mockNativeLibrary(t, (symbol, _result, parameters) => { + symbols.push(symbol); + if (symbol === 'mxc_spawn_json') { + assert.strictEqual(parameters?.length, 4); + return Object.assign(() => assert.fail('raw spawn must be asynchronous'), { + async(...args: unknown[]) { + const completion = args.pop() as (error: Error | null, status: number) => void; + calls.push(args.slice(0, 2)); + queueMicrotask(() => completion(null, 0)); + }, + }); + } + return Object.assign(() => {}, { async() {} }); + }); + try { + const native = getStreamingNative(); + for (const experimental of [0, 1]) { + await new Promise((resolve, reject) => { + native.spawn('{"process":{"commandLine":"echo"}}', experimental, [null], createAbiErrorDetail(), + (error) => error === null ? resolve() : reject(error)); + }); + } + assert.deepStrictEqual(calls.map((args) => args[1]), [0, 1]); + assert.ok(symbols.includes('mxc_spawn_json')); + assert.strictEqual(symbols.some((symbol) => symbol.includes('policy_failures')), false); + } finally { + fixture.dispose(); + } + }); + it('maps SDK status codes', () => { assert.strictEqual(_errorCodeForNativeStatus(1), 'malformed_request'); assert.strictEqual(_errorCodeForNativeStatus(2), 'unsupported_containment'); diff --git a/sdk/node/tests/unit/inprocess-run.test.ts b/sdk/node/tests/unit/inprocess-run.test.ts index d89c4b8cd2..b7afc1b1ea 100644 --- a/sdk/node/tests/unit/inprocess-run.test.ts +++ b/sdk/node/tests/unit/inprocess-run.test.ts @@ -195,6 +195,13 @@ describe('in-process asynchronous run routing', () => { }); it('rejects invalid requests at the SDK boundary', async () => { + await assert.rejects( + run({ command: 'echo hello' }, { + // @ts-expect-error Typed creation options do not accept policy failures. + policyFailures: { formatVersion: 1, results: [] }, + }), + /does not support option 'policyFailures'/, + ); await assert.rejects( run({ command: 'echo hello' }, { experimental: true } as never), /does not support option 'experimental'/, diff --git a/sdk/node/tests/unit/streaming-binding.test.ts b/sdk/node/tests/unit/streaming-binding.test.ts index a6510bf721..abeff42cfa 100644 --- a/sdk/node/tests/unit/streaming-binding.test.ts +++ b/sdk/node/tests/unit/streaming-binding.test.ts @@ -8,6 +8,7 @@ import { createStateAwareStreamingDriver, createStateAwareStreamingDriverAsync, createStreamingDriver, + createStreamingDriverFromJson, type StreamingNativeFacade, } from '../../src/bindings/streaming.js'; import { @@ -16,6 +17,10 @@ import { type NodeStreamDependencies, type NativeStreamFactory, } from '../../src/bindings/native-stdio.js'; +import { + createAbiErrorDetail, + type AbiErrorDetail, +} from '../../src/bindings/native-error.js'; class FakeNative implements StreamingNativeFacade { readonly handle = {}; @@ -25,6 +30,7 @@ class FakeNative implements StreamingNativeFacade { spawnStatus = 0; spawnRequest: string | undefined; spawnExperimental: number | undefined; + spawnError: Partial | undefined; spawnCount = 0; deferSpawn = false; spawnFailure: Error | undefined; @@ -38,6 +44,7 @@ class FakeNative implements StreamingNativeFacade { waitCount = 0; freeCount = 0; freeErrorCount = 0; + readonly freedErrors: AbiErrorDetail[] = []; warningsStatus = 0; outputMetadataStatus = 0; deferWait = false; @@ -70,11 +77,12 @@ class FakeNative implements StreamingNativeFacade { request: string, experimental: number, outHandle: unknown[], - _error: unknown, + error: AbiErrorDetail, completion: (error: Error | null, status: number) => void, ): void { this.spawnRequest = request; this.spawnExperimental = experimental; + Object.assign(error, this.spawnError); this.spawnCount += 1; if (this.spawnFailure !== undefined) { const failure = this.spawnFailure; @@ -104,7 +112,7 @@ class FakeNative implements StreamingNativeFacade { request: string, experimental: number, outHandle: unknown[], - error: unknown, + error: AbiErrorDetail, completion: (error: Error | null, status: number) => void, ): void { this.spawnWithStdinCount += 1; @@ -115,7 +123,7 @@ class FakeNative implements StreamingNativeFacade { request: string, experimental: number, outHandle: unknown[], - error: unknown, + error: AbiErrorDetail, completion: (error: Error | null, status: number) => void, ): void { this.stateAwareRequest = request; @@ -139,7 +147,7 @@ class FakeNative implements StreamingNativeFacade { request: string, experimental: number, outHandle: unknown[], - error: unknown, + error: AbiErrorDetail, completion: (error: Error | null, status: number) => void, ): void { this.stateAwareWithStdin = true; @@ -232,8 +240,10 @@ class FakeNative implements StreamingNativeFacade { queueMicrotask(() => completion(null)); } - freeError(): void { + freeError(error: AbiErrorDetail): void { this.freeErrorCount += 1; + this.freedErrors.push({ ...error }); + Object.assign(error, createAbiErrorDetail()); } freeString(value: unknown): void { @@ -272,6 +282,34 @@ class FakeStreams implements NativeStreamFactory { } describe('native streaming binding ownership', () => { + it('preserves structured creation errors and frees their JSON only with the out-error', async () => { + const request = ' { "version":"1.1.0-alpha","process":{"commandLine":"echo"} } '; + for (const json of [ + '{"policyFailures":{"formatVersion":1,"results":[]},"backendDetail":"retained"}', + '{', + ]) { + const native = new FakeNative(); + native.spawnStatus = 11; + native.spawnError = { detailsJson: Buffer.from(json + '\0') }; + await assert.rejects( + createStreamingDriverFromJson(request, native, new FakeStreams(), true), + json === '{' + ? { code: 'backend_error', message: 'native runtime returned malformed error details' } + : { code: 'policy_validation', details: { + ffiStatus: 11, policyFailures: { formatVersion: 1, results: [] }, backendDetail: 'retained', + } }, + ); + assert.strictEqual(native.spawnRequest, request); + assert.strictEqual(native.spawnExperimental, 1); + assert.strictEqual(native.spawnCount, 1); + assert.strictEqual(native.spawnWithStdinCount, 0); + assert.strictEqual(native.freeErrorCount, 1); + assert.strictEqual(native.freeCount, 0); + assert.deepStrictEqual(native.freedStrings, []); + assert.strictEqual(native.freedErrors[0].detailsJson, native.spawnError.detailsJson); + } + }); + it('awaits native state-aware spawn without using synchronous dispatch', async () => { const native = new FakeNative(); native.deferSpawn = true; diff --git a/sdk/node/tests/unit/v1-dev.test.ts b/sdk/node/tests/unit/v1-dev.test.ts index fb7b94fe73..eccfdc27eb 100644 --- a/sdk/node/tests/unit/v1-dev.test.ts +++ b/sdk/node/tests/unit/v1-dev.test.ts @@ -3,9 +3,14 @@ import assert from 'node:assert/strict'; import { once } from 'node:events'; +import { Console } from 'node:console'; +import { inspect } from 'node:util'; +import type { InspectOptions } from 'node:util'; +import { spawnSync } from 'node:child_process'; import { PassThrough, Readable } from 'node:stream'; import { afterEach, it } from 'node:test'; import { _setBindingRawRunAsyncImplementation } from '../../src/bindings/run.js'; +import { _setBindingPolicyPreparationAsyncImplementation } from '../../src/bindings/policy-preparation.js'; import { _setBindingRawSandboxProcessFactory, _setStateAwareBindingSandboxProcessFactory, @@ -15,15 +20,20 @@ import { _setExecStateAwareBindingSandboxWithPtyImplementation, } from '../../src/bindings/pty.js'; import { _setBindingStateAwareAsyncImplementation } from '../../src/bindings/state-aware.js'; +import { nativeStatusError } from '../../src/bindings/native-error.js'; import { MxcProcess, type NativeLifecycleDriver } from '../../src/v1/container-process.js'; +import { MxcError } from '../../src/v1/errors.js'; import { captureProcessOutput } from '../../src/v1/capture.js'; import { MxcPtyProcess } from '../../src/v1/mxc-pty-process.js'; import { runInContainer } from '../../src/v1/lifecycle.js'; import type { ContainerId } from '../../src/v1/lifecycle-types.js'; import * as dev from '../../src/v1/dev/index.js'; +import * as v1 from '../../src/v1/index.js'; +import * as root from '../../src/index.js'; afterEach(() => { _setBindingRawRunAsyncImplementation(); + _setBindingPolicyPreparationAsyncImplementation(); _setBindingRawSandboxProcessFactory(); _setStateAwareBindingSandboxProcessFactory(); _setSpawnBindingSandboxWithPtyJsonImplementation(); @@ -31,6 +41,46 @@ afterEach(() => { _setBindingStateAwareAsyncImplementation(); }); +it('preserves V1 structural errors and existing custom inspection subclasses', () => { + class ExistingInspection extends MxcError { + [inspect.custom](depth: number, options: InspectOptions): string { + return `existing-inspector:${depth}:${options.colors}`; + } + } + const structural: MxcError = Object.assign(new Error('structural'), { + code: 'backend_error' as const, + }); + assert.strictEqual(structural.message, 'structural'); + const subclass = new ExistingInspection('backend_error', 'generic'); + assert.ok(inspect(subclass).startsWith('existing-inspector:')); +}); + +it('keeps structured policy failures out of automatic uncaught-error text but in JSON storage', () => { + const fields = { + code: 'backend_error' as const, + message: 'Creation refused.', + details: { policyFailures: { resource: 'POLICY_UNCAUGHT_MARKER', requiredValue: '18446744073709551615' } }, + }; + const error = new MxcError(fields); + assert.deepStrictEqual(error.details, fields.details); + assert.deepStrictEqual(JSON.parse(JSON.stringify(error)).details, fields.details); + assert.deepStrictEqual({ ...error }.details, fields.details); + assert.deepStrictEqual(Object.assign({}, error).details, fields.details); + assert.strictEqual(inspect(error, { depth: null }).includes('POLICY_UNCAUGHT_MARKER'), false); + const moduleUrl = new URL('../../src/v1/errors.js', import.meta.url).href; + const result = spawnSync(process.execPath, ['--input-type=module', '-e', ` + import {inspect} from 'node:util'; + import {MxcError} from ${JSON.stringify(moduleUrl)}; + inspect.defaultOptions.depth = null; + throw new MxcError(JSON.parse(process.env.POLICY_TEST_FIELDS)); + `], { encoding: 'utf8', env: { ...process.env, POLICY_TEST_FIELDS: JSON.stringify(fields) } }); + assert.strictEqual(result.status, 1, result.stderr); + assert.ok(result.stderr.includes('Creation refused.'), result.stderr); + assert.strictEqual(result.stderr.includes('POLICY_UNCAUGHT_MARKER'), false); + assert.strictEqual(result.stderr.includes('18446744073709551615'), false); + assert.strictEqual(result.stderr.includes('policyFailures'), false); +}); + function fakeProcess(stdout = 'out', stderr = 'err'): MxcProcess { const standardOutput = new PassThrough(); const standardError = new PassThrough(); @@ -405,3 +455,496 @@ it('rejects phase mismatches, malformed options and invalid Unicode before nativ }), { code: 'malformed_request' }); await assert.rejects(dev.startContainerJson(start + '\uD800'), { code: 'malformed_request' }); }); + +function policyDetail(overrides: Partial = {}): dev.PolicyFailureDetail { + return { + failureClass: dev.PolicyFailureClasses.Filesystem, + failureReason: dev.PolicyFailureReasons.AccessExceedsCeiling, + requiredAction: dev.PolicyFailureActions.RestrictFilesystemAccess, + resourceKind: dev.PolicyFailureResourceKinds.Path, + valueKind: dev.PolicyFailureValueKinds.Access, + flags: dev.PolicyFailureFlags.HasActionableDetails | dev.PolicyFailureFlags.ResourceComplete, + requestedValue: dev.PolicyFailureAccessValues.ReadWrite, + requiredValue: dev.PolicyFailureAccessValues.ReadOnly, + ...overrides, + }; +} + +const policyResourceMarker = 'MXC_POLICY_RESOURCE_STRUCTURED_ONLY_7CDBA9'; + +function samplePolicyFailures(): dev.PolicyFailures { + return { + formatVersion: 1, + results: [ + { + version: 1, minimumReaderVersion: 1, outcome: dev.PolicyFailureOutcomes.Blocked, + details: [ + policyDetail(), + policyDetail({ resource: [] }), + policyDetail({ resource: [67, 58, 92, 0xd83d, 0xde00, 0] }), + policyDetail({ + resource: [...policyResourceMarker].map((character) => character.charCodeAt(0)), + }), + ], + }, + { + version: 0xffff_ffff, minimumReaderVersion: 0xffff_ffff, outcome: 0xffff_ffff, + details: [policyDetail({ + failureClass: 0xffff_ffff, failureReason: 0xffff_ffff, + requiredAction: 0xffff_ffff, resourceKind: 0xffff_ffff, + valueKind: 0xffff_ffff, flags: 0xffff_ffff, + requestedValue: '18446744073709551615', requiredValue: '9007199254740993', + resource: [0xd800, 0], + })], + }, + ], + }; +} + +it('round-trips merged batches without losing numeric codes, u64 values or UTF-16', () => { + const original = samplePolicyFailures(); + const stored = dev.serializePolicyFailures(original); + const restored = dev.parsePolicyFailures(stored); + assert.deepStrictEqual(restored, original); + assert.deepStrictEqual(JSON.parse(stored).results[0].details[0], { + failureClass: 2, failureReason: 2, requiredAction: 2, resourceKind: 1, + valueKind: 1, flags: 3, requestedValue: '2', requiredValue: '1', + }); + assert.strictEqual(restored.results[0].outcome, dev.PolicyFailureOutcomes.Blocked); + assert.match(stored, /"18446744073709551615"/); + assert.match(stored, /"9007199254740993"/); + const [absent, empty, text] = restored.results[0].details; + assert.strictEqual(Object.hasOwn(absent, 'resource'), false); + assert.strictEqual(dev.policyFailureResourceText(absent), undefined); + assert.strictEqual(dev.policyFailureResourceText(empty), ''); + assert.strictEqual(dev.policyFailureResourceText(text), 'C:\\😀\0'); + assert.throws(() => dev.policyFailureResourceText(restored.results[1].details[0]), + /malformed UTF-16/); + assert.throws(() => dev.policyFailureResourceText(policyDetail({ resource: [0xdc00] })), + /malformed UTF-16/); + assert.deepStrictEqual(dev.parsePolicyFailures(dev.serializePolicyFailures(restored)), original); + + const nullable = { + ...original, + results: original.results.map((result, resultIndex) => ({ + ...result, + details: result.details.map((detail, detailIndex) => ( + resultIndex === 0 && detailIndex === 0 ? { ...detail, resource: null } : detail + )), + })), + }; + const nullableJson = JSON.stringify(nullable); + const normalized = dev.parsePolicyFailures(nullableJson); + assert.deepStrictEqual(normalized, original); + assert.strictEqual(dev.serializePolicyFailures(normalized), stored); + assert.strictEqual(JSON.stringify(nullable), nullableJson); +}); + +it('separates preparation, application acceptance and strict creation without mutating inputs', async () => { + const json = ' { "version":"1.1.0-alpha","process":{"commandLine":"echo","timeout":120} } '; + const report = samplePolicyFailures(); + const terminalDiagnostic = { code: 'feedbackLimitExceeded', reason: 'terminal limit' }; + for (const spawn of [false, true]) { + const calls: unknown[][] = []; + const refusal = nativeStatusError(11, { + message: Buffer.from('creation blocked\0'), + operation: Buffer.from('CreateProcessSecurityEnvironment\0'), + nativeCode: Buffer.from(dev.PolicyFailureNativeCodes.PolicyBlocked + '\0'), + detailsJson: Buffer.from(JSON.stringify({ + policyFailures: report, backendDetail: 'retained', + policyFeedbackError: terminalDiagnostic, policyResultUnavailable: 'native result unavailable', + }) + '\0'), + }); + const recordCall = (...args: unknown[]) => { + calls.push(args); + if (calls.length === 1) throw refusal; + }; + if (spawn) { + _setBindingRawSandboxProcessFactory(async (...args) => { + recordCall(...args); + return fakeProcess(); + }); + } else { + _setBindingRawRunAsyncImplementation(async (...args) => { + recordCall(...args); + return { + stdout: 'out', stderr: 'err', exitCode: 0, timedOut: false, warnings: ['ordinary warning'], + }; + }); + } + const create = spawn ? dev.spawnJson : dev.runJson; + await assert.rejects(create(json), (error: unknown) => error === refusal); + assert.strictEqual(calls.length, 1, 'a refusal must not trigger an SDK retry'); + assert.strictEqual(refusal.message, 'creation blocked'); + assert.strictEqual(refusal.toString(), 'MxcError: creation blocked'); + assert.strictEqual(refusal.operation, 'CreateProcessSecurityEnvironment'); + assert.strictEqual(refusal.nativeCode, '0x800704EC'); + assert.strictEqual(typeof refusal.stack, 'string'); + const policyFragments = [ + policyResourceMarker, 'policyFailures', '4294967295', + '18446744073709551615', '9007199254740993', 'feedbackLimitExceeded', 'terminal limit', + ]; + let logged = ''; + const logStream = new PassThrough(); + logStream.on('data', (chunk) => { logged += chunk.toString(); }); + const logger = new Console({ + stdout: logStream, stderr: logStream, inspectOptions: { depth: null }, + }); + logger.error(refusal); + const finished = once(logStream, 'finish'); + logStream.end(); + await finished; + const renderedError = [ + refusal.message, refusal.toString(), String(refusal), refusal.stack, + inspect(refusal, { depth: null }), logged, + ].join('\n'); + for (const fragment of policyFragments) { + assert.strictEqual(renderedError.includes(fragment), false, + 'error text must not expose structured policy data'); + } + assert.strictEqual(refusal.details?.ffiStatus, 11); + assert.strictEqual(refusal.details?.backendDetail, 'retained'); + assert.deepStrictEqual(refusal.details?.policyFeedbackError, terminalDiagnostic); + assert.strictEqual(refusal.details?.policyResultUnavailable, 'native result unavailable'); + const failures = dev.policyFailuresFromError(refusal); + assert.ok(failures); + assert.deepStrictEqual(failures, report); + const restored = dev.parsePolicyFailures(dev.serializePolicyFailures(failures)); + assert.deepStrictEqual(restored, report); + assert.strictEqual( + dev.policyFailureResourceText(restored.results[0].details[3]), policyResourceMarker, + ); + const candidate: dev.PreparedPolicyRequest = { + requestJson: json.replace('"timeout":120', '"timeout":100'), + changes: [{ operation: 'replace', path: '/process/timeout', before: 120, after: 100 }], + }; + const options: dev.PolicyPreparationOptions = Object.freeze({ experimental: true }); + const before = JSON.stringify({ options, failures }); + const preparationCalls: unknown[][] = []; + _setBindingPolicyPreparationAsyncImplementation(async (...args) => { + preparationCalls.push(args); + return dev.serializePreparedPolicyRequest(candidate); + }); + const prepared = await dev.preparePolicyJson(json, failures, options); + assert.deepStrictEqual(prepared, candidate); + assert.deepStrictEqual(preparationCalls, [[json, dev.serializePolicyFailures(report), true]]); + assert.strictEqual(calls.length, 1, 'preparation does not start a workload'); + assert.strictEqual(JSON.stringify({ options, failures }), before); + // The application inspects the report before choosing to submit the candidate. + assert.deepStrictEqual(prepared.changes[0], { + operation: 'replace', path: '/process/timeout', before: 120, after: 100, + }); + const result = await create(prepared.requestJson, { experimental: true }); + assert.deepStrictEqual(result.warnings, spawn ? ['warning'] : ['ordinary warning']); + for (const fragment of policyFragments) { + assert.strictEqual(result.warnings.some((warning) => warning.includes(fragment)), false, + 'warnings must not expose structured policy data'); + } + if (result instanceof MxcProcess) result.dispose(); + assert.deepStrictEqual(calls[1], spawn + ? [candidate.requestJson, true, 100] : [candidate.requestJson, true]); + assert.deepStrictEqual(report, samplePolicyFailures()); + failures.results[0].details[0].flags = 0; + assert.deepStrictEqual(report, samplePolicyFailures(), 'extraction returned an owned report'); + const strict = await create(json); + if (strict instanceof MxcProcess) strict.dispose(); + assert.deepStrictEqual(calls[2], spawn + ? [json, false, 120] : [json, false]); + assert.strictEqual(calls.length, 3, 'policy failures must not be cached for later calls'); + assert.strictEqual(preparationCalls.length, 1, 'creation does not trigger preparation'); + } +}); + +it('retains terminal policy failures beyond preparation limits for inspection and storage', () => { + const terminal: dev.PolicyFailures = { + formatVersion: 1, + results: Array.from({ length: 65 }, (_, outcome) => ({ + version: 1, minimumReaderVersion: 1, outcome, details: [], + })), + }; + const error = new MxcError('policy_validation', 'creation blocked', { + policyFailures: terminal, + policyFeedbackError: { code: 'feedbackLimitExceeded', reason: 'input result limit exceeded' }, + }); + assert.deepStrictEqual(dev.policyFailuresFromError(error), terminal); + assert.deepStrictEqual(dev.parsePolicyFailures(dev.serializePolicyFailures(terminal)), terminal); + terminal.results.push({ version: 1, minimumReaderVersion: 1, outcome: 66, details: [] }); + assert.throws(() => dev.serializePolicyFailures(terminal), /at most 65/); + + const details: dev.PolicyFailures = { + formatVersion: 1, + results: Array.from({ length: 17 }, () => ({ + version: 1, minimumReaderVersion: 1, outcome: 1, + details: Array.from({ length: 64 }, () => policyDetail()), + })), + }; + assert.deepStrictEqual(dev.parsePolicyFailures(dev.serializePolicyFailures(details)), details); + details.results.push({ + version: 1, minimumReaderVersion: 1, outcome: 1, details: [policyDetail()], + }); + assert.throws(() => dev.serializePolicyFailures(details), /total detail storage limit/); + details.results.pop(); + details.results[0].details.push(policyDetail()); + assert.throws(() => dev.serializePolicyFailures(details), /at most 64/); + + const resources: dev.PolicyFailures = { + formatVersion: 1, + results: Array.from({ length: 9 }, () => ({ + version: 1, minimumReaderVersion: 1, outcome: 1, + details: [policyDetail({ resource: Array(32_767).fill(0) })], + })), + }; + assert.deepStrictEqual(dev.parsePolicyFailures(dev.serializePolicyFailures(resources)), resources); + resources.results.push({ + version: 1, minimumReaderVersion: 1, outcome: 1, details: [policyDetail({ resource: [] })], + }); + assert.throws(() => dev.serializePolicyFailures(resources), /total resource storage limit/); + resources.results.pop(); + resources.results[0].details.push(policyDetail({ resource: [] })); + assert.throws(() => dev.serializePolicyFailures(resources), /per-result resource limit/); +}); + +it('distinguishes absent error policy failures from malformed reports and rejects invalid shapes', async () => { + assert.strictEqual(dev.policyFailuresFromError(new Error('ordinary error')), undefined); + assert.strictEqual(dev.policyFailuresFromError(new MxcError('backend_error', 'absent')), undefined); + assert.strictEqual(dev.policyFailuresFromError(new MxcError('backend_error', 'absent', {})), undefined); + const valid = samplePolicyFailures(); + const invalidDetail = (override: Record) => ({ + formatVersion: 1, + results: [{ + version: 1, minimumReaderVersion: 1, outcome: 1, + details: [{ ...policyDetail(), ...override }], + }], + }); + const missing = invalidDetail({}); + delete (missing.results[0].details[0] as Partial).requestedValue; + for (const value of [ + null, [], {}, { results: [] }, { formatVersion: 1 }, { formatVersion: 2, results: [] }, + { ...valid, extra: true }, invalidDetail({ requestedValue: 9007199254740992 }), + invalidDetail({ requiredValue: '18446744073709551616' }), + invalidDetail({ requestedValue: '-1' }), invalidDetail({ flags: 1.5 }), + invalidDetail({ failureClass: 0x1_0000_0000 }), invalidDetail({ resource: [65536] }), + invalidDetail({ resource: [1, null] }), invalidDetail({ resource: 'path' }), missing, + ]) { + assert.throws(() => dev.parsePolicyFailures(JSON.stringify(value)), { code: 'malformed_request' }); + assert.throws(() => dev.policyFailuresFromError(new MxcError('backend_error', 'bad', { + policyFailures: value, + })), { code: 'backend_error', message: 'native policy failures are malformed' }); + } + assert.throws(() => dev.parsePolicyFailures('{'), { code: 'malformed_request' }); + assert.throws(() => dev.parsePolicyFailures(' '.repeat(4.5 * 1024 * 1024 + 1)), + { code: 'malformed_request' }); +}); + +it('rejects duplicate keys in stored observations and preparation results', () => { + const duplicate = { code: 'malformed_request', message: /duplicate JSON object keys/ }; + assert.throws(() => dev.parsePolicyFailures( + '{"formatVersion":1,"formatVersion":1,"results":[]}'), duplicate); + assert.throws(() => dev.parsePolicyFailures( + '{"formatVersion":1,"results":[],"\\u0066ormatVersion":1}'), duplicate); + assert.throws(() => dev.parsePreparedPolicyRequest( + '{"requestJson":"{}","changes":[],"changes":[]}'), duplicate); + const change = (after: string) => '{"requestJson":"{}","changes":[{"operation":"add",' + + `"path":"/x","before":null,"after":${after}}]}`; + assert.throws(() => dev.parsePreparedPolicyRequest(change('[{"k\\"}":1,"k\\u0022}":2}]')), duplicate); + // Keys repeat across separate objects; strings contain quotes, backslashes and braces. + const after = '[{"k":"{\\"k\\":1}","k\\\\":"]"},{"k":{"k":"\\\\"}}]'; + assert.deepStrictEqual(dev.parsePreparedPolicyRequest(change(after)).changes[0].after, + [{ k: '{"k":1}', 'k\\': ']' }, { k: { k: '\\' } }]); +}); + +it('rejects policy failures in creation options as an unsupported option', async () => { + _setBindingRawRunAsyncImplementation(async () => assert.fail('native must not be called')); + // @ts-expect-error Creation options do not accept policy failures. + await assert.rejects(dev.runJson('{}', { policyFailures: samplePolicyFailures() }), + { code: 'malformed_request', message: "runJson does not support option 'policyFailures'" }); + await assert.rejects(dev.preparePolicyJson('{}', samplePolicyFailures(), { + experimental: true, policyFailures: samplePolicyFailures(), + } as dev.PolicyPreparationOptions), /preparePolicyJson does not support option 'policyFailures'/); +}); + +it('keeps preparation and policy failure helpers on the Dev subpath only', () => { + for (const name of [ + 'preparePolicyJson', 'parsePreparedPolicyRequest', 'serializePreparedPolicyRequest', + 'mergePolicyFailures', 'parsePolicyFailures', 'serializePolicyFailures', + ]) { + assert.strictEqual(typeof (dev as Record)[name], 'function'); + assert.strictEqual(name in v1, false); + assert.strictEqual(name in root, false); + } +}); + +it('stores ordered edits with required before/after values and distinct real nulls', () => { + const prepared: dev.PreparedPolicyRequest = { + requestJson: ' { "version":"1.0.0","process":{"commandLine":"echo"} } ', + changes: [ + { operation: 'add', path: '/filesystem/deniedPaths', before: null, after: ['C:\\private'] }, + { operation: 'remove', path: '/processContainer/capabilities/0', before: 'internetClient', after: null }, + { operation: 'replace', path: '/a~1b/~0value', before: null, after: { allowed: false, limit: 1.5 } }, + { operation: 'replace', path: '', before: [true, 1, 'text', { nested: null }], after: null }, + { operation: 'add', path: '/explicitNull', before: null, after: null }, + { operation: 'remove', path: '/previousNull', before: null, after: null }, + ], + }; + const before = JSON.stringify(prepared); + const stored = dev.serializePreparedPolicyRequest(prepared); + assert.strictEqual(JSON.stringify(prepared), before); + const restored = dev.parsePreparedPolicyRequest(stored); + assert.deepStrictEqual(restored, prepared); + assert.ok(restored.changes.every((change) => Object.hasOwn(change, 'before') + && Object.hasOwn(change, 'after'))); + (restored.changes[0].after as dev.JsonValue[]).push('another path'); + assert.strictEqual(JSON.stringify(prepared), before, 'returned JSON values are owned'); + + const special = dev.parsePreparedPolicyRequest( + '{"requestJson":"{}","changes":[{"operation":"replace","path":"/__proto__",' + + '"before":{"__proto__":{"value":true}},"after":null}]}', + ); + assert.strictEqual(Object.hasOwn(special.changes[0].before as object, '__proto__'), true); + assert.deepStrictEqual(dev.parsePreparedPolicyRequest(dev.serializePreparedPolicyRequest(special)), special); +}); + +it('rejects malformed preparation results and invalid JSON values without input disclosure', async () => { + const valid = { + requestJson: '{}', + changes: [{ operation: 'replace', path: '/policy', before: null, after: true }], + }; + const marker = 'PREPARATION_INPUT_MUST_NOT_RENDER'; + const change = (overrides: Record) => ({ + ...valid, changes: [{ ...valid.changes[0], ...overrides }], + }); + const missingBefore = change({}); + delete (missingBefore.changes[0] as Record).before; + const missingAfter = change({}); + delete (missingAfter.changes[0] as Record).after; + const circular: Record = {}; + circular.self = circular; + const invalid = [ + null, [], {}, { ...valid, extra: true }, { ...valid, requestJson: marker }, + { ...valid, changes: null }, missingBefore, missingAfter, + change({ operation: 'copy' }), change({ path: '#/policy' }), change({ path: '/bad~2escape' }), + change({ path: '/bad~' }), change({ extra: true }), + change({ operation: 'add', before: false }), change({ operation: 'remove', after: false }), + change({ before: undefined }), change({ after: Infinity }), change({ after: NaN }), + change({ after: 1n }), change({ after: () => marker }), change({ after: new Date() }), + change({ after: [undefined] }), change({ after: { value: undefined } }), + change({ after: '\ud800' }), change({ after: circular }), + ]; + for (const value of invalid) { + assert.throws(() => dev.serializePreparedPolicyRequest(value as dev.PreparedPolicyRequest), + (error: unknown) => error instanceof MxcError && error.code === 'malformed_request' + && ![error.message, error.stack, inspect(error)].join('\n').includes(marker)); + } + for (const json of [ + `{${marker}`, JSON.stringify(missingBefore), JSON.stringify(missingAfter), + '{"requestJson":"{}","changes":[{"operation":"replace","path":"/policy","before":1e400,"after":null}]}', + ]) { + assert.throws(() => dev.parsePreparedPolicyRequest(json), + (error: unknown) => error instanceof MxcError && error.code === 'malformed_request' + && ![error.message, error.stack, inspect(error)].join('\n').includes(marker)); + _setBindingPolicyPreparationAsyncImplementation(async () => json); + await assert.rejects(dev.preparePolicyJson('{}', { formatVersion: 1, results: [] }), + (error: unknown) => error instanceof MxcError && error.code === 'backend_error' + && ![error.message, error.stack, inspect(error)].join('\n').includes(marker)); + } +}); + +it('bounds preparation request UTF-8, result JSON and edit counts before use', async () => { + const failures: dev.PolicyFailures = { formatVersion: 1, results: [] }; + let calls = 0; + _setBindingPolicyPreparationAsyncImplementation(async (requestJson) => { + calls += 1; + return JSON.stringify({ requestJson, changes: [] }); + }); + const prefix = '{"text":"'; + const suffix = '"}'; + const request = prefix + 'é'.repeat((8 * 1024 * 1024 - prefix.length - suffix.length - 1) / 2) + + 'x' + suffix; + assert.strictEqual(Buffer.byteLength(request, 'utf8'), 8 * 1024 * 1024); + assert.strictEqual((await dev.preparePolicyJson(request, failures)).requestJson, request); + for (const json of [request + ' ', '{"text":"\ud800"}', '{}\0{}', '[]', '{']) { + await assert.rejects(dev.preparePolicyJson(json, failures), { code: 'malformed_request' }); + } + await assert.rejects(dev.preparePolicyJson('{}', {} as dev.PolicyFailures), { code: 'malformed_request' }); + // @ts-expect-error Preparation validates its independent authorization options. + await assert.rejects(dev.preparePolicyJson('{}', failures, { experimental: 'true' }), + { code: 'malformed_request' }); + assert.strictEqual(calls, 1, 'invalid preparation inputs never reach native'); + const grownCandidate = { requestJson: request + ' ', changes: [] }; + const grownJson = dev.serializePreparedPolicyRequest(grownCandidate); + assert.deepStrictEqual(dev.parsePreparedPolicyRequest(grownJson), grownCandidate); + _setBindingPolicyPreparationAsyncImplementation(async () => grownJson); + assert.deepStrictEqual(await dev.preparePolicyJson('{}', failures), grownCandidate, + 'the 8 MiB input bound does not limit a candidate within the 32 MiB result bound'); + assert.throws(() => dev.parsePreparedPolicyRequest(' '.repeat(32 * 1024 * 1024 + 1)), + { code: 'malformed_request' }); + assert.throws(() => dev.serializePreparedPolicyRequest({ + requestJson: '{}', + changes: [{ operation: 'replace', path: '', before: null, after: 'é'.repeat(16 * 1024 * 1024) }], + }), { code: 'malformed_request' }); + const edit: dev.PolicyPreparationChange = { operation: 'replace', path: '', before: null, after: null }; + const candidate = { requestJson: '{}', changes: Array(4096).fill(edit) }; + assert.strictEqual(dev.parsePreparedPolicyRequest(dev.serializePreparedPolicyRequest(candidate)).changes.length, 4096); + candidate.changes.push(edit); + assert.throws(() => dev.serializePreparedPolicyRequest(candidate), { code: 'malformed_request' }); +}); + +it('merges complete refusal batches in first-seen order into independent storage', () => { + const original = samplePolicyFailures(); + const repeated = { + ...original.results[0], + details: original.results[0].details.map((detail) => ({ ...detail })), + }; + repeated.details[0].requestedValue = '0002'; + const changedOrder = { + ...original.results[0], + details: [...original.results[0].details].reverse(), + }; + const older = { formatVersion: 1, results: [original.results[0], original.results[0]] }; + const newer = { formatVersion: 1, results: [repeated, original.results[1], changedOrder] }; + const before = JSON.stringify([older, newer]); + const merged = dev.mergePolicyFailures(older, newer); + assert.deepStrictEqual(merged, { + formatVersion: 1, results: [original.results[0], original.results[1], changedOrder], + }); + assert.strictEqual(JSON.stringify([older, newer]), before); + merged.results[0].details[1].resource?.push(99); + merged.results[1].details[0].requestedValue = '0'; + assert.strictEqual(JSON.stringify([older, newer]), before, 'merge owns every nested value'); + const empty = { formatVersion: 1, results: [] }; + assert.deepStrictEqual(dev.mergePolicyFailures(empty, original), original); + assert.deepStrictEqual(dev.mergePolicyFailures(original, empty), original); +}); + +it('rejects over-limit merged storage without mutation or truncation', () => { + const empty: dev.PolicyFailures = { formatVersion: 1, results: [] }; + const results = Array.from({ length: 65 }, (_, outcome) => ({ + version: 1, minimumReaderVersion: 1, outcome, details: [], + })); + const full = { formatVersion: 1, results }; + assert.deepStrictEqual(dev.mergePolicyFailures(full, { formatVersion: 1, results: [results[0]] }), full); + const detailBatches = Array.from({ length: 18 }, (_, outcome) => ({ + version: 1, minimumReaderVersion: 1, outcome, + details: Array.from({ length: outcome === 17 ? 1 : 64 }, () => policyDetail()), + })); + const resourceBatches = Array.from({ length: 10 }, (_, outcome) => ({ + version: 1, minimumReaderVersion: 1, outcome, + details: [policyDetail({ resource: Array(outcome === 9 ? 0 : 32767).fill(0) })], + })); + const largeDetail = policyDetail({ requestedValue: '0'.repeat(2.3 * 1024 * 1024) }); + for (const [older, newer] of [ + [full, { formatVersion: 1, results: [{ ...results[0], outcome: 65 }] }], + [{ formatVersion: 1, results: detailBatches.slice(0, 17) }, + { formatVersion: 1, results: detailBatches.slice(17) }], + [{ formatVersion: 1, results: resourceBatches.slice(0, 9) }, + { formatVersion: 1, results: resourceBatches.slice(9) }], + [{ formatVersion: 1, results: [{ ...results[0], details: [largeDetail] }] }, + { formatVersion: 1, results: [{ ...results[1], details: [largeDetail] }] }], + [{ formatVersion: 1, results: [...results, results[0]] }, empty], + [empty, { formatVersion: 2, results: [] }], + ] satisfies Array<[dev.PolicyFailures, dev.PolicyFailures]>) { + const before = JSON.stringify([older, newer]); + assert.throws(() => dev.mergePolicyFailures(older, newer), { code: 'malformed_request' }); + assert.strictEqual(JSON.stringify([older, newer]), before); + } +}); diff --git a/src/ffi/mxc_ffi/src/error_detail.rs b/src/ffi/mxc_ffi/src/error_detail.rs index 7b33f640ce..b35e51e08f 100644 --- a/src/ffi/mxc_ffi/src/error_detail.rs +++ b/src/ffi/mxc_ffi/src/error_detail.rs @@ -41,6 +41,8 @@ pub struct MxcErrorDetail { pub native_code_utf8: *mut c_char, /// An actionable hint, when the failure carries one. Null otherwise. pub remediation_utf8: *mut c_char, + /// Backend-specific structured information as JSON, or null when absent. + pub details_json_utf8: *mut c_char, } impl MxcErrorDetail { @@ -51,6 +53,7 @@ impl MxcErrorDetail { operation_utf8: ptr::null_mut(), native_code_utf8: ptr::null_mut(), remediation_utf8: ptr::null_mut(), + details_json_utf8: ptr::null_mut(), } } @@ -71,6 +74,9 @@ impl MxcErrorDetail { operation_utf8: opt_cstring(error.operation.as_deref()), native_code_utf8: opt_cstring(error.native_code.as_deref()), remediation_utf8: opt_cstring(error.remediation.as_deref()), + details_json_utf8: opt_cstring( + error.details.as_deref().map(ToString::to_string).as_deref(), + ), } } @@ -81,6 +87,7 @@ impl MxcErrorDetail { free_cstr(&mut self.operation_utf8); free_cstr(&mut self.native_code_utf8); free_cstr(&mut self.remediation_utf8); + free_cstr(&mut self.details_json_utf8); } } @@ -246,4 +253,33 @@ mod tests { // SAFETY: null is explicitly part of the contract. unsafe { mxc_error_detail_free(ptr::null_mut()) }; } + + #[test] + fn policy_failures_owned_json_round_trips_and_frees_with_the_error() { + let payload = serde_json::json!({ + "policyFailures": { + "formatVersion": 1, + "results": [], + }, + "otherBackendDetail": "retained", + }); + let mut error = Error::new(ErrorCode::BackendError, "Creation refused."); + error.details = Some(Box::new(payload.clone())); + let mut detail = MxcErrorDetail::from_error(&error); + assert_eq!( + serde_json::from_str::(&read(detail.details_json_utf8).unwrap()) + .unwrap(), + payload, + ); + assert_eq!( + read(detail.message_utf8).as_deref(), + Some("Creation refused.") + ); + // SAFETY: this test owns a filled detail, then its already-freed storage. + unsafe { + mxc_error_detail_free(&mut detail); + assert!(detail.details_json_utf8.is_null()); + mxc_error_detail_free(&mut detail); + } + } } diff --git a/src/ffi/mxc_ffi/src/lib.rs b/src/ffi/mxc_ffi/src/lib.rs index d34fed405a..f09d769903 100644 --- a/src/ffi/mxc_ffi/src/lib.rs +++ b/src/ffi/mxc_ffi/src/lib.rs @@ -87,6 +87,8 @@ use mxc_sdk::v1::{probe, ProbeOutput}; use mxc_sdk::v1::{ExecutionResult, WaitResult}; mod error_detail; +#[cfg(test)] +mod policy_preparation_tests; mod pty; mod state_aware; mod streaming; @@ -413,6 +415,85 @@ fn run_json_inner(request_json_utf8: *const c_char, experimental: bool) -> MxcRu execute_output(run_json(request_json, experimental)) } +/// Prepare a restrictive candidate request and structured changes without +/// creating an environment or executing a workload. +/// +/// `experimental` authorizes experimental backends independently of the JSON +/// contract version. On success, `*out_json` is owned UTF-8 JSON released with +/// [`mxc_string_free`]. On failure it stays null and `error`, when supplied, +/// receives owned detail released with [`mxc_error_detail_free`]. +/// +/// # Safety +/// - Both input pointers must be null or valid NUL-terminated C strings. +/// - `out_json` must point to writable fresh pointer-sized storage. +/// - `error` must be null or point to writable fresh [`MxcErrorDetail`] storage. +#[no_mangle] +pub unsafe extern "C" fn mxc_prepare_policy_json( + request_json_utf8: *const c_char, + policy_failures_json_utf8: *const c_char, + experimental: i32, + out_json: *mut *mut c_char, + error: *mut MxcErrorDetail, +) -> i32 { + if !out_json.is_null() { + // SAFETY: writable pointer-sized storage is guaranteed by the caller. + unsafe { *out_json = ptr::null_mut() }; + } + if !error.is_null() { + // SAFETY: writable fresh detail storage is guaranteed by the caller. + unsafe { ptr::write(error, MxcErrorDetail::none()) }; + } + if out_json.is_null() { + return MXC_STATUS_NULL_ARGUMENT; + } + let outcome = catch_unwind(|| { + prepare_policy_json_inner( + request_json_utf8, + policy_failures_json_utf8, + experimental != 0, + ) + }) + .unwrap_or_else(|panic| { + report_panic("mxc_prepare_policy_json", &*panic); + Err(( + MXC_STATUS_PANIC, + MxcErrorDetail::from_message("the mxc engine panicked"), + )) + }); + write_json_outcome(outcome, out_json, error) +} + +fn prepare_policy_json_inner( + request_json_utf8: *const c_char, + policy_failures_json_utf8: *const c_char, + experimental: bool, +) -> Result<*mut c_char, (i32, MxcErrorDetail)> { + if request_json_utf8.is_null() || policy_failures_json_utf8.is_null() { + return Err(( + MXC_STATUS_NULL_ARGUMENT, + MxcErrorDetail::from_message("policy preparation requires both JSON inputs"), + )); + } + // SAFETY: both entrypoint inputs are valid C strings, borrowed synchronously. + let request = unsafe { cstr_to_str(request_json_utf8) }; + let failures = unsafe { cstr_to_str(policy_failures_json_utf8) }; + let (Some(request), Some(failures)) = (request, failures) else { + return Err(( + MXC_STATUS_INVALID_UTF8, + MxcErrorDetail::from_message("policy preparation JSON is not UTF-8"), + )); + }; + let failures = mxc_sdk::__ffi::decode_policy_failures_json(failures).map_err(json_error)?; + let mut options = mxc_sdk::v1::dev::PolicyPreparationOptions::default(); + options.experimental = experimental; + let prepared = + mxc_sdk::v1::dev::prepare_policy_json(request, &failures, options).map_err(json_error)?; + prepared + .to_json() + .map(|json| alloc_cstring(json.as_bytes())) + .map_err(json_error) +} + fn execute_output(output: Result) -> MxcRunResult { match output { Ok(output) => { @@ -610,11 +691,10 @@ pub unsafe extern "C" fn mxc_probe_request_json_with_error( )) }); - write_probe_outcome(outcome, out_json_utf8, out_error) + write_json_outcome(outcome, out_json_utf8, out_error) } -#[cfg(target_os = "windows")] -fn write_probe_outcome( +fn write_json_outcome( outcome: Result<*mut c_char, (i32, MxcErrorDetail)>, out_json_utf8: *mut *mut c_char, out_error: *mut MxcErrorDetail, @@ -660,9 +740,9 @@ fn probe_request_json_inner( Some(request_json) => parse_exact_probe_request(request_json) .and_then(|request| mxc_engine::probe_execution_request(Some(&request))), } - .map_err(probe_error)?; + .map_err(json_error)?; - serialize_probe_output(&output).map_err(probe_error) + serialize_probe_output(&output).map_err(json_error) } #[cfg(target_os = "windows")] @@ -711,8 +791,7 @@ fn serialize_probe_output(output: &ProbeOutput) -> Result<*mut c_char, Error> { }) } -#[cfg(target_os = "windows")] -fn probe_error(error: Error) -> (i32, MxcErrorDetail) { +fn json_error(error: Error) -> (i32, MxcErrorDetail) { ( status_from_error_code(error.code), MxcErrorDetail::from_error(&error), diff --git a/src/ffi/mxc_ffi/src/policy_preparation_tests.rs b/src/ffi/mxc_ffi/src/policy_preparation_tests.rs new file mode 100644 index 0000000000..30f8087d83 --- /dev/null +++ b/src/ffi/mxc_ffi/src/policy_preparation_tests.rs @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::*; +use std::ffi::{CStr, CString}; + +fn assert_empty(detail: &MxcErrorDetail) { + assert!(detail.message_utf8.is_null()); + assert!(detail.operation_utf8.is_null()); + assert!(detail.native_code_utf8.is_null()); + assert!(detail.remediation_utf8.is_null()); + assert!(detail.details_json_utf8.is_null()); +} + +#[test] +fn preparation_ffi_initializes_outputs_and_rejects_null_or_invalid_utf8_inputs() { + let request = CString::new("{}").unwrap(); + let failures = CString::new(r#"{"formatVersion":1,"results":[]}"#).unwrap(); + let invalid = [0xffu8, 0]; + for (request, failures, expected) in [ + (ptr::null(), failures.as_ptr(), MXC_STATUS_NULL_ARGUMENT), + (request.as_ptr(), ptr::null(), MXC_STATUS_NULL_ARGUMENT), + ( + invalid.as_ptr().cast(), + failures.as_ptr(), + MXC_STATUS_INVALID_UTF8, + ), + ( + request.as_ptr(), + invalid.as_ptr().cast(), + MXC_STATUS_INVALID_UTF8, + ), + ] { + let mut output = ptr::dangling_mut(); + let mut error = MxcErrorDetail::none(); + let status = + unsafe { mxc_prepare_policy_json(request, failures, 0, &mut output, &mut error) }; + assert_eq!(status, expected); + assert!(output.is_null()); + assert!(!error.message_utf8.is_null()); + unsafe { + mxc_error_detail_free(&mut error); + mxc_error_detail_free(&mut error); + } + assert_empty(&error); + } + let mut error = MxcErrorDetail::none(); + assert_eq!( + unsafe { + mxc_prepare_policy_json( + request.as_ptr(), + failures.as_ptr(), + 0, + ptr::null_mut(), + &mut error, + ) + }, + MXC_STATUS_NULL_ARGUMENT, + ); + assert_empty(&error); +} + +#[test] +fn preparation_ffi_limits_and_parse_failures_do_not_return_input_as_ordinary_errors() { + let empty = r#"{"formatVersion":1,"results":[]}"#; + let oversized_request = format!("INPUT_SECRET_MARKER{}", "x".repeat(8 * 1024 * 1024)); + let oversized_failures = format!("INPUT_SECRET_MARKER{}", "x".repeat(4 * 1024 * 1024)); + for (request, failures) in [ + (&*oversized_request, empty), + ("{}", &*oversized_failures), + ("{}", "INPUT_SECRET_MARKER"), + ( + r#"{"version":"1.0.0","process":{"commandLine":"INPUT_SECRET_MARKER"},"INPUT_SECRET_MARKER":true}"#, + empty, + ), + ] { + let request = CString::new(request).unwrap(); + let failures = CString::new(failures).unwrap(); + let mut output = ptr::dangling_mut(); + let mut error = MxcErrorDetail::none(); + let status = unsafe { + mxc_prepare_policy_json( + request.as_ptr(), + failures.as_ptr(), + 1, + &mut output, + &mut error, + ) + }; + assert_eq!(status, MXC_STATUS_MALFORMED_REQUEST); + assert!(output.is_null()); + let message = unsafe { CStr::from_ptr(error.message_utf8) } + .to_str() + .unwrap(); + assert!(!message.contains("INPUT_SECRET_MARKER")); + unsafe { mxc_error_detail_free(&mut error) }; + assert_empty(&error); + assert_eq!( + unsafe { + mxc_prepare_policy_json( + request.as_ptr(), + failures.as_ptr(), + 0, + &mut output, + ptr::null_mut(), + ) + }, + MXC_STATUS_MALFORMED_REQUEST, + ); + assert!(output.is_null()); + } +} + +#[test] +fn preparation_json_output_uses_existing_string_and_error_ownership() { + let json = r#"{"requestJson":"{\"allowed\":true}","changes":[{"operation":"add","path":"/allowed","before":null,"after":true}]}"#; + let mut output = ptr::null_mut(); + let mut detail = MxcErrorDetail::none(); + assert_eq!( + write_json_outcome(Ok(alloc_cstring(json.as_bytes())), &mut output, &mut detail), + MXC_STATUS_SUCCESS, + ); + let serialized = unsafe { CStr::from_ptr(output) }.to_str().unwrap(); + let prepared = mxc_sdk::v1::dev::PreparedPolicyRequest::from_json(serialized).unwrap(); + assert_eq!(prepared.request_json, r#"{"allowed":true}"#); + assert_eq!(prepared.changes[0].before, serde_json::Value::Null); + assert_empty(&detail); + unsafe { mxc_string_free(output) }; + output = ptr::null_mut(); + let mut error = Error::new( + ErrorCode::PolicyValidation, + "Policy preparation failed safely.", + ); + error.details = Some(Box::new(serde_json::json!({ + "policyFeedbackError": {"code":"unsafeReplay","reason":"unsupported observation"} + }))); + assert_eq!( + write_json_outcome(Err(json_error(error.clone())), &mut output, &mut detail), + MXC_STATUS_POLICY_VALIDATION, + ); + assert!(output.is_null()); + assert_eq!( + serde_json::from_str::( + unsafe { CStr::from_ptr(detail.details_json_utf8) } + .to_str() + .unwrap() + ) + .unwrap(), + *error.details.unwrap(), + ); + unsafe { mxc_error_detail_free(&mut detail) }; + assert_empty(&detail); +} diff --git a/src/ffi/mxc_ffi/tests/ffi.rs b/src/ffi/mxc_ffi/tests/ffi.rs index e3cd1337b1..1b3180b488 100644 --- a/src/ffi/mxc_ffi/tests/ffi.rs +++ b/src/ffi/mxc_ffi/tests/ffi.rs @@ -342,6 +342,7 @@ fn extern_spawn_json_clears_error_and_rejects_null_handle_before_parsing() { operation_utf8: ptr::dangling_mut(), native_code_utf8: ptr::dangling_mut(), remediation_utf8: ptr::dangling_mut(), + details_json_utf8: ptr::dangling_mut(), }; let invalid_utf8 = [0xff_u8, 0]; // SAFETY: the error storage is writable and holds no live detail; the null @@ -354,6 +355,7 @@ fn extern_spawn_json_clears_error_and_rejects_null_handle_before_parsing() { assert!(error.operation_utf8.is_null()); assert!(error.native_code_utf8.is_null()); assert!(error.remediation_utf8.is_null()); + assert!(error.details_json_utf8.is_null()); } #[test] diff --git a/src/mxc-sdk/Cargo.toml b/src/mxc-sdk/Cargo.toml index e22ab3c5ff..8d751e8879 100644 --- a/src/mxc-sdk/Cargo.toml +++ b/src/mxc-sdk/Cargo.toml @@ -127,6 +127,10 @@ windows = { workspace = true, features = [ ] } winresource = { version = "0.1.31", default-features = false } +[[example]] +name = "policy_feedback" +test = true + [[example]] name = "isolation_session_console" required-features = ["isolation_session"] diff --git a/src/mxc-sdk/README.md b/src/mxc-sdk/README.md index 0044616aba..fc68107cb2 100644 --- a/src/mxc-sdk/README.md +++ b/src/mxc-sdk/README.md @@ -21,6 +21,20 @@ Use [mxc_sdk::v1::dev](https://github.com/microsoft/mxc/blob/main/docs/api-refer for caller-authored MXC request JSON. It supports execution, container lifecycle operations, and validation through the in-process engine. +For native Windows ProcessContainer, a creation refusal can return owned +`PolicyFailures`. Merge observations explicitly with `merged_with`, then call +`v1::dev::prepare_policy_json` to obtain complete candidate request JSON and +structured changes without creating a container or executing a workload. +The application inspects and accepts, edits or rejects the candidate before +submitting its chosen JSON through ordinary strict `run_json` or `spawn_json`. +Each creation call attempts creation once and executes only the supplied +request. +Specific policy failures are not formatted into error or warning text. +Use names such as `PolicyFailureResult::OUTCOME_BLOCKED` and +`PolicyFailureDetail::REASON_CAPABILITY_NOT_ALLOWED` to inspect numeric codes. +See the [Dev preparation reference](https://github.com/microsoft/mxc/blob/main/docs/api-reference/rust/v1/dev.md#separate-policy-preparation) +and [Rust caller example](examples/policy_feedback.rs). + ## Run to completion and spawn Build a `ContainerRequest` directly, then choose captured output with `run` or diff --git a/src/mxc-sdk/examples/policy_feedback.rs b/src/mxc-sdk/examples/policy_feedback.rs new file mode 100644 index 0000000000..f0f5edb1b5 --- /dev/null +++ b/src/mxc-sdk/examples/policy_feedback.rs @@ -0,0 +1,212 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use mxc_sdk::v1::{ + dev::{self, JsonOptions, PolicyFailureResult, PolicyFailures, PreparedPolicyRequest}, + Error, ErrorCode, ExecutionResult, WaitResult, +}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; + +fn is_policy_refusal(error: &Error, current: &PolicyFailures) -> bool { + error.operation.as_deref() == Some("CreateProcessSecurityEnvironment") + && error.native_code.as_deref() == Some(PolicyFailures::NATIVE_CODE_POLICY_BLOCKED) + && current + .results + .last() + .is_some_and(|result| result.outcome == PolicyFailureResult::OUTCOME_BLOCKED) + && error + .details + .as_deref() + .is_none_or(|details| details.get("policyFeedbackError").is_none()) +} + +fn accepts(prepared: &PreparedPolicyRequest, approved: Option<&PreparedPolicyRequest>) -> bool { + prepared.changes.is_empty() || approved == Some(prepared) +} + +fn run_with_preparation( + config: &str, + failures: &mut Option, + mut decide: impl FnMut(&PreparedPolicyRequest) -> Result>, +) -> Result> { + let mut submitted = config.to_owned(); + if let Some(stored) = failures.as_ref() { + let prepared = dev::prepare_policy_json(config, stored, Default::default())?; + if !decide(&prepared)? { + return Err(Error::new( + ErrorCode::PolicyValidation, + "Caller approval is required; inspect the candidate output file.", + ) + .into()); + } + submitted = prepared.request_json; + } + let mut remaining = 8; // Application retry budget, including the first call. + loop { + remaining -= 1; + match dev::run_json( + &submitted, + JsonOptions { + experimental: false, + }, + ) { + Ok(output) => return Ok(output), + Err(error) => { + let Some(current) = PolicyFailures::from_error(&error)? else { + return Err(error.into()); + }; + let prior = failures.as_ref().cloned().unwrap_or_default(); + let next = prior.merged_with(¤t)?; + let progress = next != prior; + *failures = Some(next.clone()); + if remaining == 0 || !progress || !is_policy_refusal(&error, ¤t) { + return Err(error.into()); + } + let prepared = dev::prepare_policy_json(config, &next, Default::default())?; + if prepared.changes.is_empty() + || prepared.request_json == submitted + || !decide(&prepared)? + { + return Err(error.into()); + } + submitted = prepared.request_json; + } + } + } +} + +fn read_bounded(path: &Path, limit: usize) -> std::io::Result { + let mut json = String::new(); + std::fs::File::open(path)? + .take((limit + 1) as u64) + .read_to_string(&mut json)?; + if json.len() > limit { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "input file exceeds its byte limit", + )); + } + Ok(json) +} + +fn artifact_path(path: &Path) -> std::io::Result { + if path.exists() { + return path.canonicalize(); + } + let absolute = if path.is_absolute() { + path.to_owned() + } else { + std::env::current_dir()?.join(path) + }; + let parent = absolute.parent().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "output requires a parent directory", + ) + })?; + let name = absolute.file_name().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "output requires a file name", + ) + })?; + Ok(parent.canonicalize()?.join(name)) +} + +fn execute() -> Result> { + let args: Vec<_> = std::env::args_os().collect(); + if !(4..=5).contains(&args.len()) { + return Err( + "Usage: policy_feedback []" + .into(), + ); + } + let candidate_path = artifact_path(Path::new(&args[3]))?; + for source in args[1..3].iter().chain(args.get(4)) { + if artifact_path(Path::new(source))? == candidate_path { + return Err( + "The candidate output must be separate from inputs and approval storage.".into(), + ); + } + } + let config = read_bounded(Path::new(&args[1]), 8 * 1024 * 1024)?; + let mut failures = match read_bounded(Path::new(&args[2]), 4 * 1024 * 1024 + 512 * 1024) { + Ok(json) => Some(PolicyFailures::from_json(&json)?), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(error.into()), + }; + let approved = args + .get(4) + .map(|path| { + let json = read_bounded(Path::new(path), 32 * 1024 * 1024)?; + PreparedPolicyRequest::from_json(&json).map_err(Box::::from) + }) + .transpose()?; + let result = run_with_preparation(&config, &mut failures, |prepared| { + std::fs::write(&candidate_path, prepared.to_json()?)?; + Ok(accepts(prepared, approved.as_ref())) + }); + if let Some(failures) = failures { + std::fs::write(&args[2], failures.to_json()?)?; + } + let output = result?; + std::io::stdout().write_all(&output.stdout)?; + std::io::stderr().write_all(&output.stderr)?; + std::io::stdout().flush()?; + std::io::stderr().flush()?; + Ok(match output.outcome { + WaitResult::Exited(code) => code, + WaitResult::TimedOut => 124, + }) +} + +fn main() { + let code = match execute() { + Ok(code) => code, + Err(error) => { + // Display retains the generic message/status, not structured policy data. + eprintln!("{error}"); + 1 + } + }; + std::process::exit(code); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn caller_acceptance_requires_the_complete_reviewed_candidate() { + let prepared = PreparedPolicyRequest::from_json( + r#"{"requestJson":"{}","changes":[{"operation":"add","path":"/a","before":null,"after":true}]}"#, + ).unwrap(); + assert!(!accepts(&prepared, None)); + assert!(accepts(&prepared, Some(&prepared))); + let mut changed = prepared.clone(); + changed.request_json = "{\"different\":true}".into(); + assert!(!accepts(&changed, Some(&prepared))); + } + + #[test] + fn native_status_outcome_and_every_terminal_code_guard_preparation() { + let current = PolicyFailures::from_json( + r#"{"formatVersion":1,"results":[{"version":1,"minimumReaderVersion":1,"outcome":1,"details":[]}]}"#, + ).unwrap(); + let mut error = Error::new(ErrorCode::BackendError, "Policy refused creation."); + error.operation = Some("CreateProcessSecurityEnvironment".into()); + error.native_code = Some(PolicyFailures::NATIVE_CODE_POLICY_BLOCKED.into()); + assert!(is_policy_refusal(&error, ¤t)); + error.operation = Some("CreateProcessW".into()); + assert!(!is_policy_refusal(&error, ¤t)); + error.operation = Some("CreateProcessSecurityEnvironment".into()); + error.native_code = Some("0x80070005".into()); + assert!(!is_policy_refusal(&error, ¤t)); + error.native_code = Some(PolicyFailures::NATIVE_CODE_POLICY_BLOCKED.into()); + error.details = Some(Box::new(serde_json::json!({ + "policyFeedbackError": { "code": "futureTerminalCode", "reason": "terminal" } + }))); + assert!(!is_policy_refusal(&error, ¤t)); + } +} diff --git a/src/mxc-sdk/src/backends/process_container/common/base_container_helpers.rs b/src/mxc-sdk/src/backends/process_container/common/base_container_helpers.rs index 9550eaf8a8..89f37f187d 100644 --- a/src/mxc-sdk/src/backends/process_container/common/base_container_helpers.rs +++ b/src/mxc-sdk/src/backends/process_container/common/base_container_helpers.rs @@ -17,7 +17,8 @@ use crate::process_security_environment_spec::process_security_environment_layou }; use crate::process_container_common::network_policy_helpers::{ - add_default_network_capabilities, ensure_capability, INTERNET_CLIENT_CAPABILITY, + add_default_network_capabilities, capture_requests_internet_capability, ensure_capability, + INTERNET_CLIENT_CAPABILITY, }; use crate::process_container_common::secenv::SecurityEnvironmentVersion; @@ -37,10 +38,7 @@ pub(super) fn build_psec_v1_security_environment_spec( "build_psec_v1_security_environment_spec only supports PSEC major version 1" ); let mut builder = flatbuffers::FlatBufferBuilder::with_capacity(1024); - let mut capabilities = effective_capabilities(request, capture_network_decisions); - if unrestricted_host_loopback_allowed(&request.policy) { - ensure_capability(&mut capabilities, "networkLoopback"); - } + let capabilities = effective_capabilities(request, capture_network_decisions); let ui_restrictions = crate::process_container_common::job_object::to_job_object_uilimit_mask( &crate::mxc_common::ui_policy::resolve_ui_restrictions( &request.policy.ui, @@ -67,7 +65,7 @@ pub(super) fn build_psec_v1_security_environment_spec( builder.finished_data().to_vec() } -fn effective_capabilities( +pub(super) fn effective_capabilities( request: &ExecutionRequest, capture_network_decisions: bool, ) -> Vec { @@ -79,16 +77,15 @@ fn effective_capabilities( .cloned() .collect(); add_default_network_capabilities(policy, &mut capabilities); - if policy.capture_denials.is_some() - && capture_network_decisions - && policy.network_egress.is_some() - && !policy.network_proxy.is_enabled() - { + if capture_requests_internet_capability(policy, capture_network_decisions) { // Let outbound attempts pass the capability gate so Tessera's egress // policy makes and records the decision. Proxy mode must remain direct- // egress denied and therefore deliberately omits internetClient. ensure_capability(&mut capabilities, INTERNET_CLIENT_CAPABILITY); } + if unrestricted_host_loopback_allowed(policy) { + ensure_capability(&mut capabilities, "networkLoopback"); + } capabilities } diff --git a/src/mxc-sdk/src/backends/process_container/common/base_container_runner.rs b/src/mxc-sdk/src/backends/process_container/common/base_container_runner.rs index 4cefe58291..614fab7ae6 100644 --- a/src/mxc-sdk/src/backends/process_container/common/base_container_runner.rs +++ b/src/mxc-sdk/src/backends/process_container/common/base_container_runner.rs @@ -10,7 +10,6 @@ use std::ffi::c_void; use std::fmt::Write; use std::io::IsTerminal; use std::path::{Path, PathBuf}; -use std::ptr; use std::sync::Arc; use crate::learning_mode_core::{capture_diagnostics::CaptureAnalysis, AnalyzeError}; @@ -70,9 +69,10 @@ use crate::process_container_common::launch_diagnostics::{ validate_required_child_env, }; use crate::process_container_common::native_capture::CaptureSession; +use crate::process_container_common::policy_restriction; use crate::process_container_common::secenv::{ - self, ProcessSecurityEnvironment, SecurityEnvironmentStartupInfo, SecurityEnvironmentSupport, - SecurityEnvironmentVersion, PROCESS_SECURITY_ENVIRONMENT_FLAG_NONE, + self, CreationError, ProcessSecurityEnvironment, SecurityEnvironmentStartupInfo, + SecurityEnvironmentSupport, SecurityEnvironmentVersion, PROCESS_SECURITY_ENVIRONMENT_FLAG_NONE, SECURITY_ENVIRONMENT_API_SET, }; @@ -192,7 +192,7 @@ fn is_api_not_implemented(err: u32) -> bool { err == ERROR_CALL_NOT_IMPLEMENTED.0 || err == E_NOTIMPL.0 as u32 } -trait CaptureSessionOps { +pub(crate) trait CaptureSessionOps { fn environment(&self) -> HANDLE; fn finish( self: Box, @@ -213,7 +213,7 @@ impl CaptureSessionOps for CaptureSession { } } -trait CaptureSessionFactory: Send + Sync { +pub(crate) trait CaptureSessionFactory: Send + Sync { fn trace_sources( &self, ) -> Result; @@ -222,7 +222,7 @@ trait CaptureSessionFactory: Send + Sync { &self, sandbox_specification: &[u8], flags: u32, - ) -> Result, crate::learning_mode_windows::LearningModeError>; + ) -> Result, CreationError>; } struct RealCaptureSessionFactory; @@ -231,6 +231,10 @@ impl CaptureSessionFactory for RealCaptureSessionFactory { fn trace_sources( &self, ) -> Result { + #[cfg(test)] + if let Some(sources) = secenv::injected_capture_trace_sources() { + return Ok(sources); + } LearningModeApi::load().map(|api| api.trace_sources()) } @@ -238,9 +242,11 @@ impl CaptureSessionFactory for RealCaptureSessionFactory { &self, sandbox_specification: &[u8], flags: u32, - ) -> Result, crate::learning_mode_windows::LearningModeError> { - CaptureSession::begin(sandbox_specification, flags) + ) -> Result, CreationError> { + let environment = secenv::create_with_diagnostics(sandbox_specification, flags)?; + CaptureSession::begin_with_environment(environment) .map(|session| Box::new(session) as Box) + .map_err(Into::into) } } @@ -294,6 +300,8 @@ impl BaseContainerRequestDecision { pub struct BaseContainerRunner { capture_factory: Arc, request_serviceability_confirmed: bool, + #[cfg(test)] + process_launcher: fn(BaseProcessLaunch<'_>) -> windows_core::Result<()>, } impl Default for BaseContainerRunner { @@ -301,6 +309,8 @@ impl Default for BaseContainerRunner { Self { capture_factory: Arc::new(RealCaptureSessionFactory), request_serviceability_confirmed: false, + #[cfg(test)] + process_launcher: create_base_process, } } } @@ -324,18 +334,28 @@ impl BaseContainerRunner { } #[cfg(test)] - fn with_capture_factory(capture_factory: Arc) -> Self { + pub(crate) fn with_capture_factory(capture_factory: Arc) -> Self { Self { capture_factory, request_serviceability_confirmed: true, + process_launcher: create_base_process, } } + #[cfg(test)] + pub(crate) fn with_process_launcher( + mut self, + launcher: fn(BaseProcessLaunch<'_>) -> windows_core::Result<()>, + ) -> Self { + self.process_launcher = launcher; + self + } + fn build_capture_aware_security_environment_spec( &self, request: &ExecutionRequest, version: SecurityEnvironmentVersion, - ) -> Result, ScriptResponse> { + ) -> Result<(Vec, bool), ScriptResponse> { let capture_network_decisions = match request.policy.capture_denials.as_ref() { Some(_) => self .capture_factory @@ -348,9 +368,8 @@ impl BaseContainerRunner { .includes_network(), None => false, }; - Ok(build_psec_v1_security_environment_spec( - request, - version, + Ok(( + build_psec_v1_security_environment_spec(request, version, capture_network_decisions), capture_network_decisions, )) } @@ -499,6 +518,97 @@ impl BaseContainerRunner { } impl BaseContainerRunner { + /// Validate `request` exactly as strict creation would before building its + /// specification. Returns the PSEC version and the network-capture state. + fn validate_for_preparation( + request: &ExecutionRequest, + ) -> Result<(SecurityEnvironmentVersion, bool), MxcError> { + let runner = Self::for_request(request)?; + crate::mxc_common::validator::validate_common(request) + .map_err(ScriptResponse::into_mxc_error)?; + runner + .validate(request) + .map_err(ScriptResponse::into_mxc_error)?; + let version = Self::supported_security_environment_version(request) + .map_err(ScriptResponse::into_mxc_error)?; + let (_, capture_network_decisions) = runner + .build_capture_aware_security_environment_spec(request, version) + .map_err(ScriptResponse::into_mxc_error)?; + Ok((version, capture_network_decisions)) + } + + pub(crate) fn prepare_policy_request( + request: &ExecutionRequest, + failures: &crate::mxc_common::policy_failures::PolicyFailures, + ) -> Result { + let (_, capture_network_decisions) = Self::validate_for_preparation(request)?; + let candidate = + policy_restriction::prepare_request(request, failures, capture_network_decisions) + .map_err(ScriptResponse::into_mxc_error)?; + Self::validate_for_preparation(&candidate)?; + Ok(candidate) + } + + pub(crate) fn verify_prepared_policy( + expected: &ExecutionRequest, + actual: &ExecutionRequest, + ) -> Result<(), MxcError> { + fn specification(request: &ExecutionRequest) -> Result, MxcError> { + let (version, capture) = BaseContainerRunner::validate_for_preparation(request)?; + let mut canonical = request.clone(); + canonical.policy.capabilities = + crate::process_container_common::base_container_helpers::effective_capabilities( + request, capture, + ) + .into_iter() + .map(|entry| entry.to_ascii_lowercase()) + .collect(); + canonical.policy.capabilities.sort(); + canonical.policy.capabilities.dedup(); + Ok(build_psec_v1_security_environment_spec( + &canonical, version, capture, + )) + } + if specification(expected)? != specification(actual)? + || expected.source_contract != actual.source_contract + || expected.policy.capture_denials != actual.policy.capture_denials + || expected.policy.runtime_network_proxy_specified + != actual.policy.runtime_network_proxy_specified + || expected + .policy + .network_proxy + .address + .as_ref() + .map(|address| address.to_url()) + != actual + .policy + .network_proxy + .address + .as_ref() + .map(|address| address.to_url()) + { + return Err(MxcError::policy_validation( + "the prepared JSON does not reproduce the safely restricted policy", + )); + } + Ok(()) + } + + fn supported_security_environment_version( + request: &ExecutionRequest, + ) -> Result { + let version = Self::choose_min_required_psec_version_for_request(request); + let supported = secenv::supports_version(version).map_err(|error| { + ScriptResponse::unavailable(&format!( + "failed to query Process Security Environment contract support: {error}" + )) + })?; + if !supported { + return Err(Self::unsupported_psec_version_response(request, version)); + } + Ok(version) + } + /// Set up and launch the BaseContainer child, returning a [`BaseChild`] the /// caller runs to completion (blocking) or wraps in a streaming handle. When /// `capture` is set the child's stdio is wired to pipes the caller drives @@ -523,18 +633,7 @@ impl BaseContainerRunner { logger, ); - let psec_version = Self::choose_min_required_psec_version_for_request(request); - let version_supported = secenv::supports_version(psec_version).map_err(|error| { - ScriptResponse::unavailable(&format!( - "failed to query Process Security Environment contract support: {error}" - )) - })?; - if !version_supported { - return Err(Self::unsupported_psec_version_response( - request, - psec_version, - )); - } + let psec_version = Self::supported_security_environment_version(request)?; if let Some(addr) = request.policy.network_proxy.address.as_ref() { let _ = writeln!(logger, "effective proxy: {}", addr.to_url()); @@ -550,7 +649,7 @@ impl BaseContainerRunner { let _ = writeln!(logger, "{EMOJI_SECTION} SECTION: captureDenials"); } - let process_security_environment_spec = + let (process_security_environment_spec, _) = self.build_capture_aware_security_environment_spec(request, psec_version)?; let _ = writeln!( logger, @@ -592,7 +691,6 @@ impl BaseContainerRunner { working_directory.describe() ); let cwd_wide = string_util::to_wide(&working_directory.path); - let cwd_ptr = cwd_wide.as_ptr(); let identity = "".to_string(); @@ -748,10 +846,6 @@ impl BaseContainerRunner { )) })?; - let env_ptr = env_block - .as_ref() - .map(|b| b.as_ptr() as *const c_void) - .unwrap_or(ptr::null()); // Suppress the empty console window for console-subsystem children when // stdio is piped (no console is shared). In console-sharing mode (ConPTY) // the child inherits the parent's live console for interactive I/O, so @@ -770,7 +864,6 @@ impl BaseContainerRunner { let _ = writeln!(logger, "launching: {}", request.script_code); let _ = writeln!(logger, "identity: {identity}"); - let current_env_ptr = env_ptr; let current_creation_flags = creation_flags; let mut capture_session: Option> = None; @@ -790,8 +883,7 @@ impl BaseContainerRunner { capture_session = Some(session); } Err(e) => { - let msg = - format!("captureDenials: failed to start learning-mode capture: {e}"); + let msg = e.message(true); let _ = writeln!(logger, "Error: {msg}"); log_base_network_policy_audit( request, @@ -799,16 +891,14 @@ impl BaseContainerRunner { OperationStatus::Failure, logger, ); - let failure_phase = if e.is_api_unavailable() { - FailurePhase::BackendUnavailable - } else { - FailurePhase::LaunchFailed - }; - return Err(ScriptResponse::failed(failure_phase, &msg)); + return Err(e.response(true)); } } } else { - let result = secenv::create(psec_spec, PROCESS_SECURITY_ENVIRONMENT_FLAG_NONE); + let result = secenv::create_with_diagnostics( + psec_spec, + PROCESS_SECURITY_ENVIRONMENT_FLAG_NONE, + ); match result { Ok(environment) => { let _ = writeln!( @@ -818,8 +908,7 @@ impl BaseContainerRunner { security_environment = Some(environment); } Err(error) => { - let msg = - format!("failed to create the process security environment: {error}"); + let msg = error.message(false); let _ = writeln!(logger, "Error: {msg}"); log_base_network_policy_audit( request, @@ -827,12 +916,7 @@ impl BaseContainerRunner { OperationStatus::Failure, logger, ); - let failure_phase = if error.is_api_unavailable() { - FailurePhase::BackendUnavailable - } else { - FailurePhase::LaunchFailed - }; - return Err(ScriptResponse::failed(failure_phase, &msg)); + return Err(error.response(false)); } } } @@ -888,21 +972,21 @@ impl BaseContainerRunner { return Err(ScriptResponse::failed(failure_phase, &msg)); } }; - let environment = (!current_env_ptr.is_null()).then_some(current_env_ptr); - let result = unsafe { - CreateProcessW( - PCWSTR::null(), - Some(PWSTR(cmd_wide.as_mut_ptr())), - None, - None, - !inherited_handles.is_empty(), - PROCESS_CREATION_FLAGS(current_creation_flags | EXTENDED_STARTUPINFO_PRESENT.0), - environment, - PCWSTR(cwd_ptr), - &extended_startup.startup_info().StartupInfo, - &mut pi, - ) + let launch = BaseProcessLaunch { + command_line: &mut cmd_wide, + working_directory: &cwd_wide, + environment: env_block.as_deref(), + inherit_handles: !inherited_handles.is_empty(), + creation_flags: PROCESS_CREATION_FLAGS( + current_creation_flags | EXTENDED_STARTUPINFO_PRESENT.0, + ), + startup: &extended_startup, + process_information: &mut pi, }; + #[cfg(not(test))] + let result = create_base_process(launch); + #[cfg(test)] + let result = (self.process_launcher)(launch); let (success, last_error) = if result.is_ok() { (1, None) } else { @@ -1115,6 +1199,37 @@ impl BaseContainerRunner { } } +pub(crate) struct BaseProcessLaunch<'a> { + pub(crate) command_line: &'a mut [u16], + pub(crate) working_directory: &'a [u16], + pub(crate) environment: Option<&'a [u16]>, + pub(crate) inherit_handles: bool, + pub(crate) creation_flags: PROCESS_CREATION_FLAGS, + pub(crate) startup: &'a SecurityEnvironmentStartupInfo, + pub(crate) process_information: &'a mut PROCESS_INFORMATION, +} + +fn create_base_process(launch: BaseProcessLaunch<'_>) -> windows_core::Result<()> { + // SAFETY: all buffers and startup attributes are borrowed from live launch + // owners; process_information is the caller's sole writable output. + unsafe { + CreateProcessW( + PCWSTR::null(), + Some(PWSTR(launch.command_line.as_mut_ptr())), + None, + None, + launch.inherit_handles, + launch.creation_flags, + launch + .environment + .map(|block| block.as_ptr().cast::()), + PCWSTR(launch.working_directory.as_ptr()), + &launch.startup.startup_info().StartupInfo, + launch.process_information, + ) + } +} + /// A BaseContainer child launched by [`BaseContainerRunner::spawn_base`]. /// `spawn_base` resumes it only after job assignment. This owns the process /// handle, parent-side pipe ends, and per-run state it tears down once the @@ -2217,12 +2332,12 @@ mod tests { &self, _sandbox_specification: &[u8], _flags: u32, - ) -> Result, crate::learning_mode_windows::LearningModeError> - { + ) -> Result, CreationError> { self.begin_calls.fetch_add(1, Ordering::SeqCst); if let Some((function, code)) = self.begin_error { return Err( - crate::learning_mode_windows::LearningModeError::HResultCall { function, code }, + crate::learning_mode_windows::LearningModeError::HResultCall { function, code } + .into(), ); } Ok(Box::new(FakeCaptureSession { @@ -2248,6 +2363,7 @@ mod tests { Self::with_capture_factory(fake_capture_factory()) .build_capture_aware_security_environment_spec(request, version) .unwrap() + .0 } } @@ -3027,7 +3143,7 @@ mod tests { finish_calls: Arc::new(AtomicUsize::new(0)), }); let runner = BaseContainerRunner::with_capture_factory(factory); - let bytes = runner + let (bytes, capture_network_decisions) = runner .build_capture_aware_security_environment_spec( &request, SecurityEnvironmentVersion::V1_0, @@ -3036,6 +3152,10 @@ mod tests { let spec = psec_layout::root_as_process_security_environment(&bytes).unwrap(); assert_eq!(spec.capabilities(), expected_capabilities); + assert_eq!( + capture_network_decisions, + capture_enabled && trace_sources.includes_network() + ); let egress = spec.network_policy().unwrap().egress().unwrap(); assert_eq!(egress.default_action(), psec_layout::FilterAction::deny); assert!(egress.allow().is_none()); diff --git a/src/mxc-sdk/src/backends/process_container/common/launch_diagnostics.rs b/src/mxc-sdk/src/backends/process_container/common/launch_diagnostics.rs index dc07aff18c..45405b82c0 100644 --- a/src/mxc-sdk/src/backends/process_container/common/launch_diagnostics.rs +++ b/src/mxc-sdk/src/backends/process_container/common/launch_diagnostics.rs @@ -14,6 +14,7 @@ use std::path::Path; +use crate::learning_mode_windows::LearningModeError; use crate::mxc_common::models::{ExecutionRequest, ScriptResponse}; /// A structured diagnostic describing *why* a sandboxed process launch failed @@ -163,6 +164,32 @@ pub fn diagnose_create_process_failure(win32_error: u32, command_line: &str) -> } } +pub(crate) fn security_environment_failure_message( + error: &LearningModeError, + capture: bool, +) -> String { + let prefix = if capture { + "captureDenials: failed to start learning-mode capture" + } else { + "failed to create the process security environment" + }; + let mut message = format!("{prefix}: {error}"); + if matches!( + error, + LearningModeError::HResultCall { + function: "CreateProcessSecurityEnvironment", + code, + } if *code == ERROR_ACCESS_DISABLED_BY_POLICY.to_hresult().0 + ) { + message.push_str( + ". Windows blocked creation of the requested sandbox because of an IT-managed \ + policy rule (ERROR_ACCESS_DISABLED_BY_POLICY, 1260). Review the requested sandbox \ + permissions or contact your system administrator.", + ); + } + message +} + /// Diagnose a process that launched successfully but exited with a non-zero /// code. Returns `None` when no recognized condition matches. pub fn diagnose_process_exit(command_line: &str, exit_code: u32) -> Option { @@ -499,6 +526,56 @@ mod tests { assert!(!diag.message.contains("readonlyPaths")); } + #[test] + fn policy_block_at_environment_creation_has_automatic_guidance() { + let function = "CreateProcessSecurityEnvironment"; + for capture in [false, true] { + let error = LearningModeError::HResultCall { + function, + code: ERROR_ACCESS_DISABLED_BY_POLICY.to_hresult().0, + }; + let message = security_environment_failure_message(&error, capture); + assert!(message.contains("IT-managed policy rule")); + assert!(message.contains("requested sandbox permissions")); + assert!(message.contains("system administrator")); + assert!(message.contains("1260")); + assert!(message.contains("0x800704EC")); + assert!(message.contains(function)); + assert_eq!(message.starts_with("captureDenials:"), capture); + assert!(!message.contains("target executable")); + } + } + + #[test] + fn unrelated_environment_and_trace_errors_keep_their_message() { + for (function, code) in [ + ("CreateProcessSecurityEnvironment", 0x80070005_u32 as i32), + ("CreateProcessSecurityEnvironment", E_NOTIMPL.0), + ("CreateProcessSecurityEnvironment", 1260), + ( + "GetLastProcessSecurityEnvironmentPolicyResult", + ERROR_ACCESS_DISABLED_BY_POLICY.to_hresult().0, + ), + ( + "StartLearningModeTrace", + ERROR_ACCESS_DISABLED_BY_POLICY.to_hresult().0, + ), + ] { + let error = LearningModeError::HResultCall { function, code }; + for capture in [false, true] { + let prefix = if capture { + "captureDenials: failed to start learning-mode capture" + } else { + "failed to create the process security environment" + }; + assert_eq!( + security_environment_failure_message(&error, capture), + format!("{prefix}: {error}") + ); + } + } + } + #[test] fn packaged_app_detected_from_command_line() { let cmd = diff --git a/src/mxc-sdk/src/backends/process_container/common/mod.rs b/src/mxc-sdk/src/backends/process_container/common/mod.rs index d049a47ce6..28cca4dcf6 100644 --- a/src/mxc-sdk/src/backends/process_container/common/mod.rs +++ b/src/mxc-sdk/src/backends/process_container/common/mod.rs @@ -25,12 +25,18 @@ mod native_capture; #[cfg(target_os = "windows")] mod network_policy_helpers; #[cfg(target_os = "windows")] +pub(crate) mod policy_projection; +#[cfg(target_os = "windows")] +mod policy_restriction; +#[cfg(target_os = "windows")] pub mod probe; #[cfg(target_os = "windows")] pub mod process_mitigation; #[cfg(target_os = "windows")] mod secenv; #[cfg(target_os = "windows")] +mod secenv_policy; +#[cfg(target_os = "windows")] pub use native_capture::CaptureSession; #[cfg(target_os = "windows")] pub use secenv::{ diff --git a/src/mxc-sdk/src/backends/process_container/common/native_capture.rs b/src/mxc-sdk/src/backends/process_container/common/native_capture.rs index afd958dbae..0a396bb91f 100644 --- a/src/mxc-sdk/src/backends/process_container/common/native_capture.rs +++ b/src/mxc-sdk/src/backends/process_container/common/native_capture.rs @@ -66,6 +66,12 @@ impl CaptureSession { /// so it is not leaked. pub fn begin(sandbox_specification: &[u8], flags: u32) -> Result { let environment = secenv::create(sandbox_specification, flags)?; + Self::begin_with_environment(environment) + } + + pub(crate) fn begin_with_environment( + environment: ProcessSecurityEnvironment, + ) -> Result { // SAFETY: `environment` was just created and remains live until this // session closes it. let trace = unsafe { start_trace(environment.raw()) }; diff --git a/src/mxc-sdk/src/backends/process_container/common/network_policy_helpers.rs b/src/mxc-sdk/src/backends/process_container/common/network_policy_helpers.rs index 4fdd00bd28..bf19885297 100644 --- a/src/mxc-sdk/src/backends/process_container/common/network_policy_helpers.rs +++ b/src/mxc-sdk/src/backends/process_container/common/network_policy_helpers.rs @@ -90,11 +90,42 @@ pub(crate) fn add_default_network_capabilities( } } +pub(crate) fn capture_requests_internet_capability( + policy: &ContainerPolicy, + capture_network_decisions: bool, +) -> bool { + policy.capture_denials.is_some() + && capture_network_decisions + && policy.network_egress.is_some() + && !policy.network_proxy.is_enabled() +} + #[cfg(test)] mod tests { use super::*; use crate::mxc_common::models::{NetworkEgressPolicy, NetworkIngressPolicy}; + #[test] + fn capture_capability_requires_the_selected_network_trace_and_direct_mode() { + let mut policy = ContainerPolicy { + capture_denials: Some(Default::default()), + network_egress: Some(NetworkEgressPolicy::default()), + ..Default::default() + }; + assert!(capture_requests_internet_capability(&policy, true)); + assert!(!capture_requests_internet_capability(&policy, false)); + policy.network_proxy = crate::mxc_common::models::ProxyConfig { + address: Some(ProxyAddress::new("127.0.0.1".into(), 8080)), + }; + assert!(!capture_requests_internet_capability(&policy, true)); + policy.network_proxy = Default::default(); + policy.network_egress = None; + assert!(!capture_requests_internet_capability(&policy, true)); + policy.network_egress = Some(Default::default()); + policy.capture_denials = None; + assert!(!capture_requests_internet_capability(&policy, true)); + } + #[test] fn directional_egress_defaults_and_rules_select_capability() { let mut policy = ContainerPolicy { diff --git a/src/mxc-sdk/src/backends/process_container/common/policy_preparation_backend_tests.rs b/src/mxc-sdk/src/backends/process_container/common/policy_preparation_backend_tests.rs new file mode 100644 index 0000000000..3d63230fc7 --- /dev/null +++ b/src/mxc-sdk/src/backends/process_container/common/policy_preparation_backend_tests.rs @@ -0,0 +1,427 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::*; +use crate::mxc_common::logger::{Logger, Mode}; +use crate::mxc_common::sandbox_process::{SandboxBackend, StdioMode}; +use crate::process_container_common::base_container_runner::BaseContainerRunner; +use crate::process_container_common::test_env::PolicyCapabilityGuard; +use crate::process_security_environment_spec::process_security_environment_layout as psec; +use crate::v1::dev::{PolicyChangeOperation, PolicyFailureDetail as D, PolicyFailureResult as R}; +use serde_json::{json, Value}; +use std::io::Read; +use std::sync::Arc; + +fn failures(detail: D) -> PolicyFailures { + PolicyFailures { + results: vec![R { + outcome: R::OUTCOME_BLOCKED, + details: vec![detail], + ..Default::default() + }], + ..Default::default() + } +} + +fn remove_capability(capability: &str) -> PolicyFailures { + failures(D { + failure_class: D::CLASS_ENFORCEMENT_MODE, + failure_reason: D::REASON_CAPABILITY_NOT_ALLOWED, + required_action: D::ACTION_REMOVE_CAPABILITY, + resource_kind: D::RESOURCE_KIND_CAPABILITY, + flags: D::FLAG_HAS_ACTIONABLE_DETAILS | D::FLAG_RESOURCE_COMPLETE, + resource: Some(capability.encode_utf16().collect()), + ..Default::default() + }) +} + +fn execute_accepted_candidate(json: &str) { + let request = parsed_request(json); + let mut runner = BaseContainerRunner::with_capture_factory(Arc::new(UnusedCapture)) + .with_process_launcher(launch_fixed_fixture); + STATE.with(|state| { + let mut state = state.borrow_mut(); + state.status = S_OK; + state.handle = true; + }); + let mut process = runner + .spawn(&request, &mut Logger::new(Mode::Buffer), StdioMode::Pipes) + .expect("strictly create the accepted JSON request"); + let mut stdout = String::new(); + process + .take_stdout() + .unwrap() + .read_to_string(&mut stdout) + .unwrap(); + assert_eq!(stdout.trim(), "mxc-policy-child"); + assert_eq!(process.wait().unwrap(), 0); + drop(process); +} + +#[test] +fn policy_preparation_preserves_contract_metadata_presence_and_inferred_cwd_in_submitted_psec() { + let _host = PolicyCapabilityGuard::set(true, true, true); + let mut api = reset(); + api.version_support = Some(version); + let _scope = ApiScope(INJECTED_API.with(|slot| slot.replace(Some(api)))); + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + let work = root.join("work"); + let unrelated = root.join("unrelated"); + let denied = root.join("denied"); + let enumerated = root.join("enumerated"); + for path in [&work, &unrelated, &denied, &enumerated] { + std::fs::create_dir(path).unwrap(); + } + let observed = failures(D { + failure_class: D::CLASS_FILESYSTEM, + failure_reason: D::REASON_ACCESS_EXCEEDS_CEILING, + required_action: D::ACTION_RESTRICT_FILESYSTEM_ACCESS, + resource_kind: D::RESOURCE_KIND_PATH, + value_kind: D::VALUE_KIND_ACCESS, + flags: D::FLAG_HAS_ACTIONABLE_DETAILS | D::FLAG_RESOURCE_COMPLETE, + requested_value: D::ACCESS_READ_WRITE, + required_value: D::ACCESS_READ_ONLY, + resource: Some(work.to_string_lossy().encode_utf16().collect()), + }); + for (index, version) in ["0.9.0-alpha", "1.0.0", "1.1.0-alpha"].iter().enumerate() { + let original = json!({ + "version": version, "containerId": "preserved-container", "containment": "process", + "lifecycle": { "destroyOnExit": true, "preservePolicy": false }, + "process": { + "commandLine": "cmd.exe /d /c echo mxc-policy-child", + "inheritDefaultEnv": false, "timeout": 5000, + }, + "filesystem": { + "readwritePaths": [work, unrelated, denied], + "readonlyPaths": [], "deniedPaths": [denied], + }, + "processContainer": { "filesystem": { "enumeratePaths": [enumerated] } }, + }); + let original_json = original.to_string(); + let _ = parsed_request(&original_json); + let prepared = prepare_candidate(&original_json, &observed); + let candidate: Value = serde_json::from_str(&prepared.request_json).unwrap(); + assert_eq!(candidate["version"], original["version"]); + assert_eq!(candidate["containerId"], original["containerId"]); + assert_eq!(candidate["containment"], original["containment"]); + assert_eq!(candidate["lifecycle"], original["lifecycle"]); + assert!(candidate["process"].get("env").is_none()); + assert_eq!( + candidate["process"]["inheritDefaultEnv"], + original["process"]["inheritDefaultEnv"] + ); + assert_eq!( + candidate["process"]["commandLine"], + original["process"]["commandLine"] + ); + assert_eq!( + candidate["filesystem"]["readwritePaths"], + json!([unrelated, denied]) + ); + assert_eq!(candidate["filesystem"]["readonlyPaths"], json!([work])); + assert_eq!( + candidate["filesystem"]["deniedPaths"], + original["filesystem"]["deniedPaths"] + ); + assert_eq!(candidate["processContainer"], original["processContainer"]); + assert_eq!(candidate["process"]["cwd"], json!(work)); + assert_eq!(prepared.changes.len(), 3); + for change in &prepared.changes { + if change.path == "/process/cwd" { + assert_eq!(change.operation, PolicyChangeOperation::Add); + assert!(original.pointer(&change.path).is_none()); + assert!(change.before.is_null()); + } else { + assert_eq!(change.operation, PolicyChangeOperation::Replace); + assert_eq!(original.pointer(&change.path), Some(&change.before)); + } + assert_eq!(candidate.pointer(&change.path), Some(&change.after)); + } + let unchanged = prepare_candidate(&prepared.request_json, &observed); + assert!(unchanged.changes.is_empty()); + assert_eq!(unchanged.request_json, prepared.request_json); + execute_accepted_candidate(&prepared.request_json); + STATE.with(|state| { + let state = state.borrow(); + assert_eq!(state.specifications.len(), index + 1); + assert_eq!(state.closes, index + 1); + assert_eq!(state.frees, 0); + let spec = + psec::root_as_process_security_environment(state.specifications.last().unwrap()) + .unwrap(); + assert_eq!(spec.version().minor(), 1); + assert_eq!( + spec.fs_read_write().unwrap().iter().collect::>(), + [unrelated.to_str().unwrap()] + ); + assert_eq!( + spec.fs_read_only().unwrap().iter().collect::>(), + [work.to_str().unwrap()] + ); + assert_eq!( + spec.fs_deny().unwrap().iter().collect::>(), + [denied.to_str().unwrap()] + ); + assert_eq!( + spec.fs_enumerate().unwrap().iter().collect::>(), + [enumerated.to_str().unwrap()] + ); + }); + } +} + +#[test] +fn policy_preparation_preserves_current_proxy_certificate_and_capture_fields() { + let _host = PolicyCapabilityGuard::set(true, true, true); + let mut api = reset(); + api.version_support = Some(version); + let _scope = ApiScope(INJECTED_API.with(|slot| slot.replace(Some(api)))); + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + for (index, port) in [7011, 7022].iter().enumerate() { + let certificate = root.join(format!("certificate-{port}.pem")); + std::fs::write(&certificate, b"certificate grant fixture").unwrap(); + let proxy = format!("http://127.0.0.1:{port}"); + let peer = format!("mxc-preparation-peer-{port}"); + let original = json!({ + "version":"1.1.0-alpha", "containment":"processcontainer", + "process": { + "commandLine": "cmd.exe /d /c echo mxc-policy-child", + "cwd": root, "timeout": 5000, + }, + "filesystem": { "readonlyPaths": [certificate] }, + "network": { + "egress": { "default":"deny" }, + "ingress": { "default":"allow", "hostLoopback":"deny" }, + }, + "runtimeConfig": { "networkProxy":proxy }, + "processContainer": { + "capabilities":["registryRead", "registryWrite"], + "network": { "allowedProxyPeer":peer }, + } + }); + let prepared = prepare_candidate(&original.to_string(), &remove_capability("registryRead")); + let candidate: Value = serde_json::from_str(&prepared.request_json).unwrap(); + assert_eq!(prepared.changes.len(), 1); + assert_eq!(candidate["runtimeConfig"], original["runtimeConfig"]); + assert_eq!(candidate["network"], original["network"]); + assert_eq!(candidate["filesystem"], original["filesystem"]); + assert_eq!( + candidate["processContainer"]["network"], + original["processContainer"]["network"] + ); + execute_accepted_candidate(&prepared.request_json); + STATE.with(|state| { + let state = state.borrow(); + assert_eq!(state.specifications.len(), index + 1); + assert_eq!(state.closes, index + 1); + let spec = + psec::root_as_process_security_environment(state.specifications.last().unwrap()) + .unwrap(); + assert_eq!( + spec.fs_read_only().unwrap().get(0), + certificate.to_str().unwrap() + ); + let network = spec.network_policy().unwrap(); + assert_eq!(network.proxy().unwrap().url(), Some(proxy.as_str())); + assert_eq!(network.allowed_appcontainer_peer(), Some(peer.as_str())); + assert!(network.egress().is_none()); + assert!(network.ingress().is_none()); + assert_eq!( + spec.capabilities(), + Some("registryWrite,privateNetworkClientServer") + ); + }); + for sources in [ + crate::learning_mode_windows::LearningModeTraceSources::AccessOnly, + crate::learning_mode_windows::LearningModeTraceSources::AccessAndNetwork, + ] { + STATE.with(|state| state.borrow_mut().capture_sources = sources); + let mut capture = original.clone(); + capture["processContainer"]["learningMode"] = json!(true); + capture["processContainer"]["captureDenials"] = json!({ + "mode":"allow", "outputPath":root.join(format!("capture-{port}.json")), "retainEtl":true, + }); + let prepared = prepare_candidate( + &capture.to_string(), + &remove_capability("permissiveLearningMode"), + ); + let candidate: Value = serde_json::from_str(&prepared.request_json).unwrap(); + assert_eq!(prepared.changes.len(), 1); + assert_eq!( + prepared.changes[0].path, + "/processContainer/captureDenials/mode" + ); + capture["processContainer"]["captureDenials"]["mode"] = json!("block"); + assert_eq!(candidate, capture); + assert!(!candidate["processContainer"]["capabilities"] + .as_array() + .unwrap() + .iter() + .any(|capability| capability == "learningModeLogging")); + let request = parsed_request(&prepared.request_json); + let effective = + crate::process_container_common::base_container_helpers::effective_capabilities( + &request, + sources.includes_network(), + ); + assert!(effective.iter().any(|entry| entry == "learningModeLogging")); + assert!(!effective + .iter() + .any(|entry| entry == "permissiveLearningMode")); + assert!(!effective.iter().any(|entry| entry == "internetClient")); + } + } +} + +#[test] +fn policy_preparation_preserves_supported_process_container_alias_for_capability_and_capture_edits() +{ + let _host = PolicyCapabilityGuard::set(true, true, true); + let mut api = reset(); + api.version_support = Some(version); + let _scope = ApiScope(INJECTED_API.with(|slot| slot.replace(Some(api)))); + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + STATE.with(|state| { + state.borrow_mut().capture_sources = + crate::learning_mode_windows::LearningModeTraceSources::AccessOnly; + }); + for section in ["appContainer", "processContainer"] { + let mut original = json!({ + "version": "0.9.0-alpha", + "containment": "processcontainer", + "process": { "commandLine": "must-not-run", "cwd": root }, + "network": { + "egress": { "default": "deny" }, + "ingress": { "default": "deny", "hostLoopback": "deny" }, + }, + }); + original[section] = json!({ "capabilities": ["registryRead", "registryWrite"] }); + let prepared = prepare_candidate(&original.to_string(), &remove_capability("registryRead")); + let mut expected = original.clone(); + expected[section]["capabilities"] = json!(["registryWrite"]); + assert_eq!( + serde_json::from_str::(&prepared.request_json).unwrap(), + expected + ); + assert_eq!(prepared.changes.len(), 1); + assert_eq!(prepared.changes[0].path, format!("/{section}/capabilities")); + original[section]["learningMode"] = json!(true); + original[section]["captureDenials"] = json!({ + "mode": "allow", "outputPath": root.join("capture.json"), "retainEtl": true, + }); + let prepared = prepare_candidate( + &original.to_string(), + &remove_capability("permissiveLearningMode"), + ); + let mut expected = original.clone(); + expected[section]["captureDenials"]["mode"] = json!("block"); + assert_eq!( + serde_json::from_str::(&prepared.request_json).unwrap(), + expected + ); + assert_eq!( + prepared.changes[0].path, + format!("/{section}/captureDenials/mode") + ); + original[section]["captureDenials"]["mode"] = json!("block"); + let prepared = prepare_candidate( + &original.to_string(), + &remove_capability("learningModeLogging"), + ); + let mut expected = original.clone(); + expected[section]["learningMode"] = json!(false); + expected[section] + .as_object_mut() + .unwrap() + .remove("captureDenials"); + assert_eq!( + serde_json::from_str::(&prepared.request_json).unwrap(), + expected + ); + assert!(prepared + .changes + .iter() + .all(|change| change.path.starts_with(&format!("/{section}/")))); + } + STATE.with(|state| { + let state = state.borrow(); + assert!(state.specifications.is_empty()); + assert!(!state.calls.contains(&"create")); + assert!(!state.calls.contains(&"launch")); + }); +} + +#[test] +fn policy_preparation_keeps_unchanged_member_text_verbatim() { + let _host = PolicyCapabilityGuard::set(true, true, true); + let mut api = reset(); + api.version_support = Some(version); + let _scope = ApiScope(INJECTED_API.with(|slot| slot.replace(Some(api)))); + let directory = tempfile::tempdir().unwrap(); + let comment = r#"{"big":123456789012345678901234567890,"dup":1,"dup":2}"#; + let original = format!( + r#"{{"version":"0.9.0-alpha","_comment":{comment},"containment":"processcontainer","process":{{"commandLine":"must-not-run","cwd":{}}},"network":{{"egress":{{"default":"deny"}},"ingress":{{"default":"deny","hostLoopback":"deny"}}}},"processContainer":{{"capabilities":["registryRead","registryWrite"]}}}}"#, + serde_json::to_string(directory.path()).unwrap() + ); + let prepared = prepare_candidate(&original, &remove_capability("registryRead")); + assert_eq!(prepared.changes.len(), 1); + assert!( + prepared + .request_json + .contains(&format!(r#""_comment":{comment}"#)), + "{}", + prepared.request_json + ); + assert_eq!( + serde_json::from_str::(&prepared.request_json).unwrap()["processContainer"] + ["capabilities"], + json!(["registryWrite"]) + ); +} + +#[test] +fn policy_preparation_unknown_or_unsafe_observations_fail_before_native_creation() { + let _host = PolicyCapabilityGuard::set(true, true, true); + let mut api = reset(); + api.version_support = Some(version); + let _scope = ApiScope(INJECTED_API.with(|slot| slot.replace(Some(api)))); + let request = r#"{"version":"1.0.0","process":{"commandLine":"INPUT_SECRET_MARKER"},"processContainer":{"capabilities":["registryRead"]}}"#; + for fault in ["version", "outcome", "action", "flags", "resource"] { + let mut observed = remove_capability("registryRead"); + match fault { + "version" => observed.results[0].minimum_reader_version = u32::MAX, + "outcome" => observed.results[0].outcome = R::OUTCOME_EVALUATION_FAILED, + "action" => observed.results[0].details[0].required_action = u32::MAX, + "flags" => observed.results[0].details[0].flags = D::FLAG_RESOURCE_UNAVAILABLE, + "resource" => observed.results[0].details[0].resource = Some(vec![0xd800]), + _ => unreachable!(), + } + let error = crate::v1::dev::prepare_policy_json(request, &observed, Default::default()) + .unwrap_err(); + assert_eq!( + error.code, + crate::v1::ErrorCode::PolicyValidation, + "{fault}" + ); + assert_eq!( + error.message, "policy preparation cannot safely enforce the requested policy", + "{fault}" + ); + assert_eq!( + PolicyFailures::from_error(&error).unwrap(), + Some(observed), + "{fault}" + ); + STATE.with(|state| { + let state = state.borrow(); + assert!(state.specifications.is_empty()); + assert!(!state.calls.contains(&"create")); + assert!(!state.calls.contains(&"launch")); + assert_eq!(state.frees, 0); + assert_eq!(state.closes, 0); + }); + } +} diff --git a/src/mxc-sdk/src/backends/process_container/common/policy_projection.rs b/src/mxc-sdk/src/backends/process_container/common/policy_projection.rs new file mode 100644 index 0000000000..117be806c7 --- /dev/null +++ b/src/mxc-sdk/src/backends/process_container/common/policy_projection.rs @@ -0,0 +1,371 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Builds the policy preparation result for ProcessContainer: restricts the +//! parsed request, projects the restriction back onto the request JSON the +//! application authored, and checks that the candidate reproduces it. + +use crate::mxc_common::models::{ + CaptureDenialsMode, ExecutionRequest, LearningModeCapability, NetworkAction, +}; +use crate::mxc_common::mxc_error::{MxcError, MxcErrorCode}; +use crate::mxc_common::policy_failures::PolicyFailures; +use crate::mxc_common::policy_preparation::{collect_changes, PreparedPolicyRequest}; +use crate::mxc_common::splice::serialize_preserving_unchanged_members; +use crate::process_container_common::base_container_runner::BaseContainerRunner; +use crate::process_container_common::working_directory::{ + try_launch_working_directory, LaunchWorkingDirectory, +}; +use serde_json::Value; + +/// Prepare a candidate for `request`, which was parsed from `original_json`. +/// `reparse` parses candidate JSON through the same contract and options as +/// the original request. +pub(crate) fn prepare_candidate( + original_json: &str, + request: &ExecutionRequest, + failures: &PolicyFailures, + reparse: impl FnOnce(&str) -> Option, +) -> Result { + let restricted = BaseContainerRunner::prepare_policy_request(request, failures) + .map_err(backend_preparation_error)?; + let original: Value = serde_json::from_str(original_json) + .map_err(|_| MxcError::malformed_request("invalid policy preparation request JSON"))?; + let (candidate, original_cwd) = project_request(&original, request, &restricted)?; + let candidate_json = match (original.as_object(), candidate.as_object()) { + (Some(original), Some(candidate)) => { + serialize_preserving_unchanged_members(original_json, original, candidate) + } + _ => None, + } + .ok_or_else(projection_error)?; + let reparsed = reparse(&candidate_json).ok_or_else(projection_error)?; + BaseContainerRunner::verify_prepared_policy(&restricted, &reparsed) + .map_err(backend_preparation_error)?; + if resolve_launch_directory(&reparsed)?.path != original_cwd { + return Err(projection_error()); + } + let mut changes = Vec::new(); + collect_changes("", Some(&original), Some(&candidate), &mut changes)?; + Ok(PreparedPolicyRequest { + request_json: if changes.is_empty() { + original_json.to_owned() + } else { + candidate_json + }, + changes, + }) +} + +// Backend messages can quote request data; preparation reports only the category. +fn backend_preparation_error(mut error: MxcError) -> MxcError { + error.message = match error.code { + MxcErrorCode::BackendUnavailable => { + "native Windows ProcessContainer cannot enforce policy preparation on this host" + } + _ => "policy preparation cannot safely enforce the requested policy", + } + .to_owned(); + error.remediation = None; + error +} + +fn projection_error() -> MxcError { + MxcError::policy_validation( + "the restrictive policy cannot be represented safely in the request contract", + ) +} + +fn resolve_launch_directory( + request: &ExecutionRequest, +) -> Result { + try_launch_working_directory(request).map_err(|_| { + MxcError::policy_validation( + "policy preparation cannot resolve the launch directory safely; check filesystem availability", + ) + }) +} + +fn is_reserved_learning_capability(entry: &str) -> bool { + LearningModeCapability::from_capability_name(entry.trim_matches([' ', '\t'])).is_some() +} + +/// Returns the candidate request JSON and the launch directory of the +/// original request, which the candidate must keep. +fn project_request( + original: &Value, + before: &ExecutionRequest, + after: &ExecutionRequest, +) -> Result<(Value, String), MxcError> { + let mut json = original.clone(); + let process_container = if original.get("appContainer").is_some() { + "appContainer" + } else { + "processContainer" + }; + let action = |value| { + Value::String( + match value { + NetworkAction::Allow => "allow", + NetworkAction::Deny => "deny", + } + .to_owned(), + ) + }; + for (name, old, new) in [ + ( + "readwritePaths", + &before.policy.readwrite_paths, + &after.policy.readwrite_paths, + ), + ( + "readonlyPaths", + &before.policy.readonly_paths, + &after.policy.readonly_paths, + ), + ( + "deniedPaths", + &before.policy.denied_paths, + &after.policy.denied_paths, + ), + ] { + if old != new { + set( + &mut json, + &["filesystem", name], + Some(project_path_list( + original.pointer(&format!("/filesystem/{name}")), + old, + new, + )?), + )?; + } + } + let authorable_capabilities = |entries: &[String]| { + entries + .iter() + .filter(|entry| !is_reserved_learning_capability(entry)) + .cloned() + .collect::>() + }; + let old = authorable_capabilities(&before.policy.capabilities); + let new = authorable_capabilities(&after.policy.capabilities); + if old != new { + set( + &mut json, + &[process_container, "capabilities"], + Some(Value::from(new)), + )?; + } + if after.policy.capture_denials.is_none() + && !after.policy.capabilities.iter().any(|entry| { + LearningModeCapability::from_capability_name(entry.trim_matches([' ', '\t'])) + == Some(LearningModeCapability::Learning) + }) + && original + .get(process_container) + .and_then(|section| section.get("learningMode")) + == Some(&Value::Bool(true)) + { + set( + &mut json, + &[process_container, "learningMode"], + Some(Value::Bool(false)), + )?; + } + if before.policy.network_egress != after.policy.network_egress { + let old = before + .policy + .network_egress + .as_ref() + .ok_or_else(projection_error)?; + let new = after + .policy + .network_egress + .as_ref() + .ok_or_else(projection_error)?; + if old.default != new.default { + set( + &mut json, + &["network", "egress", "default"], + Some(action(new.default)), + )?; + } + if old.allow != new.allow { + if !new.allow.is_empty() { + return Err(projection_error()); + } + set( + &mut json, + &["network", "egress", "allow"], + Some(Value::Array(Vec::new())), + )?; + } + if old.deny != new.deny { + return Err(projection_error()); + } + } + if before.policy.network_ingress != after.policy.network_ingress { + let old = before + .policy + .network_ingress + .as_ref() + .ok_or_else(projection_error)?; + let new = after + .policy + .network_ingress + .as_ref() + .ok_or_else(projection_error)?; + if old.default != new.default { + set( + &mut json, + &["network", "ingress", "default"], + Some(action(new.default)), + )?; + } + if old.host_loopback != new.host_loopback { + set( + &mut json, + &["network", "ingress", "hostLoopback"], + Some(action(new.host_loopback)), + )?; + } + } + if before.policy.network_proxy.is_enabled() && !after.policy.network_proxy.is_enabled() { + set(&mut json, &["runtimeConfig", "networkProxy"], None)?; + } + if before.policy.allowed_proxy_peer != after.policy.allowed_proxy_peer { + set( + &mut json, + &[process_container, "network", "allowedProxyPeer"], + after.policy.allowed_proxy_peer.clone().map(Value::String), + )?; + } + match ( + &before.policy.capture_denials, + &after.policy.capture_denials, + ) { + (Some(_), None) => set(&mut json, &[process_container, "captureDenials"], None)?, + (Some(old), Some(new)) if old.mode != new.mode => { + set( + &mut json, + &[process_container, "captureDenials", "mode"], + Some(Value::String( + match new.mode { + CaptureDenialsMode::Block => "block", + CaptureDenialsMode::Allow => "allow", + } + .to_owned(), + )), + )?; + } + (None, Some(_)) => return Err(projection_error()), + _ => {} + } + let original_cwd = resolve_launch_directory(before)?; + let candidate_cwd = resolve_launch_directory(after)?; + if original_cwd.path != candidate_cwd.path { + set( + &mut json, + &["process", "cwd"], + Some(Value::String(original_cwd.path.clone())), + )?; + } + Ok((json, original_cwd.path)) +} + +fn project_path_list( + authored: Option<&Value>, + before: &[String], + after: &[String], +) -> Result { + use std::collections::HashMap; + fn counts(paths: &[String]) -> HashMap<&str, usize> { + let mut counts = HashMap::new(); + for path in paths { + *counts.entry(path.as_str()).or_insert(0usize) += 1; + } + counts + } + let old_counts = counts(before); + let new_counts = counts(after); + let mut remaining = new_counts.clone(); + let mut projected = Vec::new(); + if let Some(value) = authored.filter(|value| !value.is_null()) { + for value in value.as_array().ok_or_else(projection_error)? { + let path = value.as_str().ok_or_else(projection_error)?; + // Normalization can suppress entries already covered by more + // restrictive lists. Preserve that unrelated authored data. + if !old_counts.contains_key(path) { + projected.push(value.clone()); + } else if let Some(count) = remaining.get_mut(path).filter(|count| **count > 0) { + *count -= 1; + projected.push(value.clone()); + } + } + } + let mut seen = HashMap::new(); + for path in after { + let count = seen.entry(path.as_str()).or_insert(0usize); + *count += 1; + if *count > old_counts.get(path.as_str()).copied().unwrap_or(0) { + projected.push(Value::String(path.clone())); + } + } + Ok(Value::Array(projected)) +} + +fn set(json: &mut Value, path: &[&str], value: Option) -> Result<(), MxcError> { + let (last, parents) = path.split_last().ok_or_else(projection_error)?; + let mut current = json; + for parent in parents { + let object = current.as_object_mut().ok_or_else(projection_error)?; + current = object + .entry((*parent).to_owned()) + .or_insert_with(|| Value::Object(Default::default())); + if current.is_null() { + *current = Value::Object(Default::default()); + } + } + let object = current.as_object_mut().ok_or_else(projection_error)?; + match value { + Some(value) => { + object.insert((*last).to_owned(), value); + } + None => { + object.remove(*last); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn projection_keeps_suppressed_authoring_when_only_runtime_paths_change() { + let before = vec!["work".into(), "work".into()]; + let after = vec!["work".into()]; + let authored = json!(["work", "denied", "work", "enumerated", "denied"]); + assert_eq!( + project_path_list(Some(&authored), &before, &after).unwrap(), + json!(["work", "denied", "enumerated", "denied"]), + ); + let authored = json!(["read", "denied", "read"]); + assert_eq!( + project_path_list( + Some(&authored), + &["read".into(), "read".into()], + &["read".into(), "work".into()], + ) + .unwrap(), + json!(["read", "denied", "work"]), + ); + assert_eq!( + project_path_list(Some(&Value::Null), &[], &["work".into()]).unwrap(), + json!(["work"]), + ); + } +} diff --git a/src/mxc-sdk/src/backends/process_container/common/policy_restriction.rs b/src/mxc-sdk/src/backends/process_container/common/policy_restriction.rs new file mode 100644 index 0000000000..93de8729a8 --- /dev/null +++ b/src/mxc-sdk/src/backends/process_container/common/policy_restriction.rs @@ -0,0 +1,517 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Derive conservative request restrictions from caller-owned observations. + +use std::collections::HashSet; + +use crate::mxc_common::filesystem_canonical::{canonicalize_allowing_absent_tail, PathCanonical}; +use crate::mxc_common::filesystem_probe::FilesystemProbe; +use crate::mxc_common::models::{ + CaptureDenialsMode, ContainerPolicy, ExecutionRequest, NetworkAction, ScriptResponse, +}; +use crate::mxc_common::policy_failures::PolicyFailures; +use crate::process_container_common::base_container_helpers::{ + effective_capabilities, unrestricted_host_loopback_allowed, +}; +use crate::process_container_common::network_policy_helpers::{ + capture_requests_internet_capability, ensure_capability, +}; +use crate::process_container_common::secenv_policy::RestrictionHint; +use crate::process_container_common::secenv_policy::{PolicyResult, POLICY_RESULT_VERSION}; + +fn contradicts_earlier(hints: &[RestrictionHint], index: usize) -> bool { + let hint = &hints[index]; + hints[..index] + .iter() + .any(|previous| match (previous, hint) { + ( + RestrictionHint::RestrictPath { path: a, .. }, + RestrictionHint::RestrictPath { path: b, .. }, + ) => same_path(a, b) && previous != hint, + (RestrictionHint::ReducePathCount { .. }, RestrictionHint::ReducePathCount { .. }) => { + previous != hint + } + _ => false, + }) +} + +fn restrict_request( + original: &ExecutionRequest, + failures: &PolicyFailures, + capture_network_decisions: bool, + resolve: &mut impl FnMut(&str) -> Result, +) -> Result { + failures.validate()?; + let mut merged = Vec::new(); + for result in &failures.results { + if result.version != POLICY_RESULT_VERSION + || result.minimum_reader_version != POLICY_RESULT_VERSION + { + return Err("unsupported native policy-result version".into()); + } + let native = PolicyResult::from(result); + let hints = native + .restriction_hints() + .ok_or("the observations contain an unsupported or non-actionable policy result")?; + if (0..hints.len()).any(|index| contradicts_earlier(&hints, index)) { + return Err("a native policy-result batch contains contradictory constraints".into()); + } + combine_restriction_hints(&mut merged, &hints); + } + let mut candidate = original.clone(); + for _ in 0..=merged.len() { + let applicable = applicable_hints(&candidate, &merged, capture_network_decisions); + if applicable.is_empty() { + return Ok(candidate); + } + candidate = + restrict_with_hints(&candidate, &applicable, capture_network_decisions, resolve)?; + } + Err("the policy restrictions did not converge".into()) +} + +pub(super) fn prepare_request( + original: &ExecutionRequest, + failures: &PolicyFailures, + capture_network_decisions: bool, +) -> Result { + let mut probe = FilesystemProbe::default(); + restrict_request(original, failures, capture_network_decisions, &mut |path| { + probe.run(path, canonicalize_allowing_absent_tail) + }) + .map_err(|reason| { + let message = "Policy preparation cannot apply these observations safely."; + ScriptResponse::rejected(message).with_error( + crate::mxc_common::mxc_error::MxcError::policy_validation(message).with_details( + serde_json::json!({ + "policyFailures": failures, + "policyFeedbackError": { "code": "unsafeReplay", "reason": reason }, + }), + ), + ) + }) +} + +fn applicable_hints( + request: &ExecutionRequest, + hints: &[RestrictionHint], + capture_network_decisions: bool, +) -> Vec { + hints + .iter() + .filter_map(|hint| match hint { + RestrictionHint::RemoveCapability(capability) => { + has_capability(request, capability, capture_network_decisions).then(|| hint.clone()) + } + RestrictionHint::RestrictPath { path, maximum, .. } => { + let requested = access_at(&request.policy, path); + (requested > *maximum).then(|| RestrictionHint::RestrictPath { + path: path.clone(), + requested, + maximum: *maximum, + }) + } + RestrictionHint::ReducePathCount { maximum, .. } => { + let requested = native_path_count(&request.policy); + (requested > *maximum).then_some(RestrictionHint::ReducePathCount { + requested, + maximum: *maximum, + }) + } + }) + .collect() +} + +/// Fold `hints` into `merged`, keeping the strictest maximum for each target. +fn combine_restriction_hints(merged: &mut Vec, hints: &[RestrictionHint]) { + for hint in hints { + let previous = merged.iter().position(|previous| match (previous, hint) { + ( + RestrictionHint::RemoveCapability(first), + RestrictionHint::RemoveCapability(second), + ) => capability_matches(first, second), + ( + RestrictionHint::RestrictPath { path: first, .. }, + RestrictionHint::RestrictPath { path: second, .. }, + ) => same_path(first, second), + (RestrictionHint::ReducePathCount { .. }, RestrictionHint::ReducePathCount { .. }) => { + true + } + _ => false, + }); + match previous { + None => merged.push(hint.clone()), + Some(index) => match (&mut merged[index], hint) { + ( + RestrictionHint::RestrictPath { maximum, .. }, + RestrictionHint::RestrictPath { maximum: next, .. }, + ) + | ( + RestrictionHint::ReducePathCount { maximum, .. }, + RestrictionHint::ReducePathCount { maximum: next, .. }, + ) => { + *maximum = (*maximum).min(*next); + } + _ => {} + }, + } + } +} + +fn restrict_with_hints( + original: &ExecutionRequest, + hints: &[RestrictionHint], + capture_network_decisions: bool, + resolve: &mut impl FnMut(&str) -> Result, +) -> Result { + let mut candidate = original.clone(); + for (index, hint) in hints.iter().enumerate() { + if contradicts_earlier(hints, index) { + return Err("the native details contain contradictory constraints".into()); + } + match hint { + RestrictionHint::RemoveCapability(capability) => remove_capability( + &mut candidate, + original, + capability, + capture_network_decisions, + )?, + RestrictionHint::RestrictPath { + path, + requested, + maximum, + } => restrict_path( + &mut candidate.policy, + &original.policy, + path, + *requested, + *maximum, + resolve, + )?, + RestrictionHint::ReducePathCount { .. } => {} + } + } + for hint in hints { + match hint { + RestrictionHint::RemoveCapability(capability) => { + if has_capability(&candidate, capability, capture_network_decisions) { + return Err("a forbidden capability still has an effective source".into()); + } + } + RestrictionHint::ReducePathCount { requested, maximum } => { + if native_path_count(&original.policy) != *requested { + return Err("the native path count disagrees with the submitted request".into()); + } + for paths in [ + &mut candidate.policy.readwrite_paths, + &mut candidate.policy.readonly_paths, + &mut candidate.policy.denied_paths, + ] { + let mut seen = HashSet::new(); + paths.retain(|path| seen.insert(path.clone())); + } + if native_path_count(&candidate.policy) > *maximum { + return Err( + "the path limit cannot be met by exact deduplication; MXC will not \ + discard distinct grants or exceptions" + .into(), + ); + } + } + RestrictionHint::RestrictPath { .. } => {} + } + } + let before = effective_capabilities(original, capture_network_decisions); + if effective_capabilities(&candidate, capture_network_decisions) + .iter() + .any(|capability| { + !(before + .iter() + .any(|previous| capability_matches(previous, capability)) + || (capability_matches(capability, "learningModeLogging") + && before + .iter() + .any(|previous| capability_matches(previous, "permissiveLearningMode")))) + }) + { + return Err("the revised request would introduce a new effective capability".into()); + } + + Ok(candidate) +} + +fn native_path_count(policy: &ContainerPolicy) -> u64 { + (policy.readwrite_paths.len() + policy.readonly_paths.len() + policy.denied_paths.len()) as u64 +} + +fn capability_matches(entry: &str, capability: &str) -> bool { + entry + .trim_matches([' ', '\t']) + .eq_ignore_ascii_case(capability.trim_matches([' ', '\t'])) +} + +fn has_capability(request: &ExecutionRequest, capability: &str, network_capture: bool) -> bool { + (capability.eq_ignore_ascii_case("networkLoopback") + && unrestricted_host_loopback_allowed(&request.policy)) + || effective_capabilities(request, network_capture) + .iter() + .any(|entry| capability_matches(entry, capability)) +} + +fn remove_capability( + candidate: &mut ExecutionRequest, + original: &ExecutionRequest, + capability: &str, + network_capture: bool, +) -> Result<(), String> { + if capability.is_empty() + || capability.contains(',') + || capability.chars().any(char::is_control) + || !has_capability(original, capability, network_capture) + { + return Err("the forbidden capability does not match the submitted request".into()); + } + let policy = &mut candidate.policy; + policy + .capabilities + .retain(|entry| !capability_matches(entry, capability)); + if capability.eq_ignore_ascii_case("internetClient") { + if let Some(egress) = &mut policy.network_egress { + egress.default = NetworkAction::Deny; + egress.allow.clear(); + } + if capture_requests_internet_capability(policy, network_capture) { + omit_capture(policy); + } + } else if capability.eq_ignore_ascii_case("privateNetworkClientServer") { + if let Some(ingress) = &mut policy.network_ingress { + ingress.default = NetworkAction::Deny; + } + disable_proxy(policy, network_capture); + } else if capability.eq_ignore_ascii_case("networkLoopback") { + if let Some(ingress) = &mut policy.network_ingress { + ingress.host_loopback = NetworkAction::Deny; + } + if policy.network_proxy.is_enabled() && policy.allowed_proxy_peer.is_none() { + disable_proxy(policy, network_capture); + } + } else if capability.eq_ignore_ascii_case("permissiveLearningMode") { + if let Some(capture) = &mut policy.capture_denials { + capture.mode = CaptureDenialsMode::Block; + ensure_capability(&mut policy.capabilities, "learningModeLogging"); + } + } else if capability.eq_ignore_ascii_case("learningModeLogging") { + omit_capture(policy); + } + Ok(()) +} + +fn omit_capture(policy: &mut ContainerPolicy) { + policy.capture_denials = None; + policy.capabilities.retain(|capability| { + !capability_matches(capability, "permissiveLearningMode") + && !capability_matches(capability, "learningModeLogging") + }); +} + +fn disable_proxy(policy: &mut ContainerPolicy, network_capture: bool) { + if !policy.network_proxy.is_enabled() { + return; + } + // Proxy mode omits the direct policy tables. Do not activate allowances + // that were ineffective in the submitted proxy form. + if let Some(egress) = &mut policy.network_egress { + egress.default = NetworkAction::Deny; + egress.allow.clear(); + } + if let Some(ingress) = &mut policy.network_ingress { + ingress.default = NetworkAction::Deny; + ingress.host_loopback = NetworkAction::Deny; + } + policy.network_proxy = Default::default(); + policy.runtime_network_proxy_specified = false; + policy.allowed_proxy_peer = None; + if capture_requests_internet_capability(policy, network_capture) { + // Leaving proxy mode must not synthesize a new direct-egress capability. + omit_capture(policy); + } +} + +fn literal_path(path: &str) -> Result { + let bytes = path.as_bytes(); + let drive = + bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'\\'; + let unc = path.starts_with("\\\\") && !path.starts_with("\\\\?") && !path.starts_with("\\\\."); + if (!drive && !unc) || path.contains(['/', '*', '?']) || path.chars().any(char::is_control) { + return Err("filesystem restriction requires an absolute literal DOS or UNC path".into()); + } + let normalized = path.trim_end_matches('\\'); + let normalized = if drive && normalized.len() == 2 { + &path[..3] + } else { + normalized + }; + let tail = normalized + .get(if drive { 3.. } else { 2.. }) + .ok_or("filesystem restriction cannot reinterpret non-canonical path components")?; + if (!tail.is_empty() + && tail.split('\\').any(|part| { + part.is_empty() + || part == "." + || part == ".." + || part.ends_with(['.', ' ']) + || part.contains(':') + })) + || (unc && tail.split('\\').count() < 2) + { + return Err( + "filesystem restriction cannot reinterpret non-canonical path components".into(), + ); + } + Ok(normalized.to_owned()) +} + +fn contains(parent: &str, child: &str) -> bool { + child.eq_ignore_ascii_case(parent) + || (child + .get(..parent.len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(parent)) + && child.as_bytes().get(parent.len()) == Some(&b'\\')) +} + +fn same_path(first: &str, second: &str) -> bool { + first + .trim_end_matches('\\') + .eq_ignore_ascii_case(second.trim_end_matches('\\')) +} + +fn access_at(policy: &ContainerPolicy, point: &str) -> u64 { + let mut best = None; + let mut access = 0; + for (paths, value) in [ + (&policy.readwrite_paths, 2), + (&policy.readonly_paths, 1), + (&policy.denied_paths, 0), + ] { + for path in paths { + let path = path.trim_end_matches('\\'); + if contains(path, point) && best.is_none_or(|length| path.len() >= length) { + best = Some(path.len()); + access = value; + } + } + } + access +} + +fn restrict_path( + policy: &mut ContainerPolicy, + original: &ContainerPolicy, + path: &str, + requested: u64, + maximum: u64, + resolve: &mut impl FnMut(&str) -> Result, +) -> Result<(), String> { + if maximum > 1 || requested > 2 || maximum >= requested { + return Err("the filesystem constraint is not a supported narrowing instruction".into()); + } + let point = literal_path(path)?; + if access_at(original, &point) != requested { + return Err("the native filesystem witness disagrees with the submitted request".into()); + } + if access_at(policy, &point) <= maximum { + return Ok(()); + } + let readonly_grants: Vec<_> = if maximum == 1 { + policy + .readwrite_paths + .iter() + .filter(|path| same_path(path, &point)) + .cloned() + .collect() + } else { + Vec::new() + }; + if maximum == 1 && readonly_grants.is_empty() { + return Err( + "read-only restriction requires an existing matching read-write grant; MXC will not \ + introduce a new grant name or downgrade an unrelated ancestor" + .into(), + ); + } + for path in policy + .readwrite_paths + .iter() + .chain(&policy.readonly_paths) + .chain(&policy.denied_paths) + .map(String::as_str) + .chain(std::iter::once(point.as_str())) + { + let spelling = literal_path(path)?; + let resolved = match resolve(path)? { + PathCanonical::Canonical(path) => literal_path(&path)?, + _ => { + return Err( + "filesystem restriction cannot resolve every relevant path safely".into(), + ) + } + }; + if !spelling.eq_ignore_ascii_case(&resolved) { + return Err( + "filesystem restriction will not guess the provenance of an aliased path".into(), + ); + } + } + if original.readwrite_paths.iter().any(|path| { + let path = path.trim_end_matches('\\'); + path.len() == 2 || (path.starts_with("\\\\") && path[2..].split('\\').count() == 2) + }) { + return Err( + "volume/share-root write grants have no supported automatic restriction".into(), + ); + } + + let before = policy.clone(); + policy + .readwrite_paths + .retain(|path| !same_path(path, &point)); + if maximum == 0 { + policy + .readonly_paths + .retain(|path| !same_path(path, &point)); + if !policy + .denied_paths + .iter() + .any(|path| same_path(path, &point)) + { + policy.denied_paths.push(point.clone()); + } + } else { + for grant in readonly_grants { + if !policy.readonly_paths.contains(&grant) { + policy.readonly_paths.push(grant); + } + } + } + // Access is piecewise constant between the old/new path boundaries. + let points = before + .readwrite_paths + .iter() + .chain(&before.readonly_paths) + .chain(&before.denied_paths) + .map(String::as_str) + .chain(std::iter::once(point.as_str())); + if access_at(policy, &point) > maximum + || points + .into_iter() + .any(|path| access_at(policy, path) > access_at(&before, path)) + { + return Err("the proposed filesystem restriction could increase effective access".into()); + } + Ok(()) +} + +#[cfg(test)] +#[path = "policy_restriction_tests.rs"] +mod tests; diff --git a/src/mxc-sdk/src/backends/process_container/common/policy_restriction_tests.rs b/src/mxc-sdk/src/backends/process_container/common/policy_restriction_tests.rs new file mode 100644 index 0000000000..6d620394a9 --- /dev/null +++ b/src/mxc-sdk/src/backends/process_container/common/policy_restriction_tests.rs @@ -0,0 +1,749 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::*; +use crate::mxc_common::models::{ + CaptureDenialsConfig, NetworkEgressPolicy, NetworkIngressPolicy, NetworkRule, ProxyAddress, + ProxyConfig, +}; +use crate::process_container_common::secenv::SecurityEnvironmentVersion; + +fn canonical(path: &str) -> Result { + Ok(PathCanonical::Canonical(path.to_string())) +} + +fn request(policy: ContainerPolicy) -> ExecutionRequest { + ExecutionRequest { + script_code: "cmd /c exit 0".into(), + policy, + ..Default::default() + } +} + +const ABSOLUTE_PATH: &str = "filesystem restriction requires an absolute literal DOS or UNC path"; +const NON_CANONICAL: &str = + "filesystem restriction cannot reinterpret non-canonical path components"; +const ROOT_GRANT: &str = "volume/share-root write grants have no supported automatic restriction"; +const READONLY_NEEDS_GRANT: &str = "read-only restriction requires an existing matching read-write grant; MXC will not introduce a new grant name or downgrade an unrelated ancestor"; + +fn remove(capability: &str) -> RestrictionHint { + RestrictionHint::RemoveCapability(capability.into()) +} + +fn restrict(path: &str, requested: u64, maximum: u64) -> RestrictionHint { + RestrictionHint::RestrictPath { + path: path.into(), + requested, + maximum, + } +} + +fn rejection( + original: &ExecutionRequest, + hints: &[RestrictionHint], + capture: bool, + resolve: &mut impl FnMut(&str) -> Result, +) -> String { + restrict_with_hints(original, hints, capture, resolve).expect_err("the hints must be rejected") +} + +fn observation( + hints: &[RestrictionHint], +) -> crate::mxc_common::policy_failures::PolicyFailureResult { + use crate::mxc_common::policy_failures::{PolicyFailureDetail, PolicyFailureResult}; + PolicyFailureResult { + outcome: 1, + details: hints + .iter() + .map(|hint| match hint { + RestrictionHint::RemoveCapability(capability) => PolicyFailureDetail { + failure_class: 2, + failure_reason: 1, + required_action: 1, + resource_kind: 2, + flags: 3, + resource: Some(capability.encode_utf16().collect()), + ..Default::default() + }, + RestrictionHint::RestrictPath { + path, + requested, + maximum, + } => PolicyFailureDetail { + failure_class: 2, + failure_reason: 2, + required_action: 2, + resource_kind: 1, + value_kind: 1, + flags: 3, + requested_value: *requested, + required_value: *maximum, + resource: Some(path.encode_utf16().collect()), + }, + RestrictionHint::ReducePathCount { requested, maximum } => PolicyFailureDetail { + failure_class: 2, + failure_reason: 4, + required_action: 3, + value_kind: 2, + flags: 1, + requested_value: *requested, + required_value: *maximum, + ..Default::default() + }, + }) + .collect(), + ..Default::default() + } +} + +#[test] +fn policy_restriction_replays_strengthened_observations_against_fresh_requests() { + let original = request(ContainerPolicy { + readwrite_paths: vec![r"C:\work".into(), r"C:\unrelated".into()], + ..Default::default() + }); + let mut failures = PolicyFailures::default(); + failures.append(observation(&[restrict(r"C:\work", 2, 1)])); + failures.append(observation(&[restrict(r"C:\work", 1, 0)])); + let candidate = restrict_request(&original, &failures, false, &mut canonical).unwrap(); + assert_eq!(access_at(&candidate.policy, r"C:\work"), 0); + assert_eq!(candidate.policy.readwrite_paths, [r"C:\unrelated"]); + assert_eq!( + original.policy.readwrite_paths, + [r"C:\work", r"C:\unrelated"] + ); + assert_eq!(failures.results[1].details[0].requested_value, 1); + let already_satisfied = restrict_request(&candidate, &failures, false, &mut canonical).unwrap(); + assert_eq!( + already_satisfied.policy.denied_paths, + candidate.policy.denied_paths + ); + + let mut changed = original.clone(); + changed + .policy + .readwrite_paths + .push(r"C:\new-request".into()); + let candidate = restrict_request(&changed, &failures, false, &mut canonical).unwrap(); + assert!(candidate + .policy + .readwrite_paths + .contains(&r"C:\new-request".into())); +} + +#[test] +fn policy_restriction_enforces_constraints_reintroduced_by_another_reduction() { + let mut policy = ContainerPolicy { + capabilities: vec!["permissiveLearningMode".into()], + capture_denials: Some(CaptureDenialsConfig::default()), + ..Default::default() + }; + policy.capture_denials.as_mut().unwrap().mode = CaptureDenialsMode::Allow; + let original = request(policy); + let mut failures = PolicyFailures::default(); + failures.append(observation(&[remove("learningModeLogging")])); + failures.append(observation(&[remove("permissiveLearningMode")])); + let candidate = restrict_request(&original, &failures, false, &mut canonical).unwrap(); + assert!(candidate.policy.capture_denials.is_none()); + assert!(!has_capability(&candidate, "learningModeLogging", false)); + assert!(!has_capability(&candidate, "permissiveLearningMode", false)); + assert_eq!( + original.policy.capture_denials.as_ref().unwrap().mode, + CaptureDenialsMode::Allow + ); +} + +#[test] +fn policy_preparation_empty_and_satisfied_observations_preserve_the_request() { + let original = request(ContainerPolicy { + capabilities: vec!["registryRead".into()], + ..Default::default() + }); + let mut failures = PolicyFailures::default(); + for hint in [None, Some(remove("registryWrite"))] { + if let Some(hint) = hint { + failures.append(observation(&[hint])); + } + let candidate = restrict_request(&original, &failures, false, &mut canonical).unwrap(); + assert_eq!( + serde_json::to_value(&candidate).unwrap(), + serde_json::to_value(&original).unwrap(), + ); + } +} + +#[test] +fn policy_restriction_rejects_unknown_and_contradictory_batches_before_creation() { + let original = request(ContainerPolicy { + readwrite_paths: vec![r"C:\work".into()], + ..Default::default() + }); + let mut failures = PolicyFailures::default(); + failures.append(observation(&[ + restrict(r"C:\work", 2, 1), + restrict(r"C:\work", 2, 0), + ])); + assert_eq!( + restrict_request(&original, &failures, false, &mut canonical).unwrap_err(), + "a native policy-result batch contains contradictory constraints" + ); + failures.results[0].details.truncate(1); + failures.results[0].details[0].required_action = u32::MAX; + assert_eq!( + restrict_request(&original, &failures, false, &mut canonical).unwrap_err(), + "the observations contain an unsupported or non-actionable policy result" + ); +} + +#[test] +fn policy_restriction_unsafe_replay_keeps_specifics_out_of_error_and_warning_text() { + let mut failures = PolicyFailures::default(); + let mut result = observation(&[remove("POLICY_RESOURCE_MARKER")]); + result.details[0].required_action = u32::MAX; + failures.append(result); + let original = request(ContainerPolicy::default()); + let response = prepare_request(&original, &failures, false).unwrap_err(); + assert_eq!( + response.error_message, + "Policy preparation cannot apply these observations safely." + ); + assert!(response.warnings().is_empty()); + let error = crate::mxc_engine::Error::from(response.into_mxc_error()); + assert!(!error.to_string().contains("POLICY_RESOURCE_MARKER")); + assert_eq!(PolicyFailures::from_error(&error).unwrap(), Some(failures)); + let diagnostic = &error.details.as_deref().unwrap()["policyFeedbackError"]; + assert_eq!(diagnostic["code"], "unsafeReplay"); + assert!(diagnostic["reason"] + .as_str() + .is_some_and(|reason| !reason.is_empty())); +} + +#[test] +fn restriction_filesystem_narrowing_preserves_and_ignores_enumeration_bootstrap() { + let desktop = r"C:\Users\AdminUser\Desktop"; + let enumeration = vec![ + r"C:\Users\AdminUser".to_string(), + r"C:\bootstrap".to_string(), + r"C:\bootstrap".to_string(), + ]; + for (path, maximum) in [(r"C:\Users\AdminUser\Desktop\denied", 0), (desktop, 1)] { + let original = request(ContainerPolicy { + readwrite_paths: vec![desktop.into()], + enumerate_paths: enumeration.clone(), + ..Default::default() + }); + let candidate = restrict_with_hints( + &original, + &[restrict(path, 2, maximum)], + false, + &mut |path| { + assert!( + !enumeration.iter().any(|entry| entry == path), + "enumeration-only paths must not enter access-restriction resolution", + ); + canonical(path) + }, + ) + .expect("a valid filesystem narrowing must not depend on enumeration grants"); + assert_eq!(candidate.policy.enumerate_paths, enumeration); + assert_eq!(access_at(&candidate.policy, path), maximum); + assert_eq!(original.policy.readwrite_paths, [desktop]); + } +} + +#[test] +fn restriction_removes_all_internet_sources_without_dropping_deny_rules() { + let original = request(ContainerPolicy { + capabilities: vec![ + "internetClient".into(), + "registryRead".into(), + "learningModeLogging".into(), + ], + network_egress: Some(NetworkEgressPolicy { + default: NetworkAction::Allow, + allow: vec![NetworkRule::default()], + deny: vec![NetworkRule::default()], + }), + capture_denials: Some(CaptureDenialsConfig::default()), + ..Default::default() + }); + let before = serde_json::to_string(&original).unwrap(); + let candidate = + restrict_with_hints(&original, &[remove("InternetClient")], true, &mut canonical).unwrap(); + assert_eq!( + candidate.policy.network_egress.as_ref().unwrap().default, + NetworkAction::Deny + ); + assert!(candidate + .policy + .network_egress + .as_ref() + .unwrap() + .allow + .is_empty()); + assert_eq!( + candidate.policy.network_egress.as_ref().unwrap().deny.len(), + 1 + ); + assert!(candidate.policy.capture_denials.is_none()); + assert_eq!(candidate.policy.capabilities, ["registryRead"]); + assert!(!has_capability(&candidate, "internetClient", true)); + assert_eq!(serde_json::to_string(&original).unwrap(), before); +} + +#[test] +fn restriction_preserves_unchanged_capability_spellings() { + let original = request(ContainerPolicy { + capabilities: vec![" registryRead\t".into(), " registryWrite ".into()], + network_ingress: Some(NetworkIngressPolicy { + default: NetworkAction::Allow, + host_loopback: NetworkAction::Allow, + }), + network_proxy: ProxyConfig { + address: Some(ProxyAddress::from_url( + "http://user:password@127.0.0.1:8080", + "127.0.0.1".into(), + 8080, + )), + }, + runtime_network_proxy_specified: true, + ..Default::default() + }); + let candidate = restrict_with_hints( + &original, + &[remove("registryRead"), remove("networkLoopback")], + false, + &mut canonical, + ) + .unwrap(); + assert_eq!(candidate.policy.capabilities, [" registryWrite "]); + assert!(!candidate.policy.network_proxy.is_enabled()); + assert!(original.policy.network_proxy.is_enabled()); +} + +#[test] +fn restriction_network_capture_uses_the_sources_of_the_submitted_attempt() { + let original = request(ContainerPolicy { + network_egress: Some(NetworkEgressPolicy::default()), + capture_denials: Some(CaptureDenialsConfig::default()), + ..Default::default() + }); + assert_eq!( + rejection( + &original, + &[remove("internetClient")], + false, + &mut canonical + ), + "the forbidden capability does not match the submitted request" + ); + assert!( + restrict_with_hints(&original, &[remove("internetClient")], true, &mut canonical) + .unwrap() + .policy + .capture_denials + .is_none() + ); +} + +#[test] +fn restriction_disables_proxy_without_synthesizing_direct_network_capture_access() { + let original = request(ContainerPolicy { + capabilities: vec!["learningModeLogging".into()], + network_egress: Some(NetworkEgressPolicy::default()), + network_ingress: Some(NetworkIngressPolicy { + default: NetworkAction::Allow, + host_loopback: NetworkAction::Allow, + }), + network_proxy: ProxyConfig { + address: Some(ProxyAddress::new("127.0.0.1".into(), 8080)), + }, + runtime_network_proxy_specified: true, + capture_denials: Some(CaptureDenialsConfig::default()), + ..Default::default() + }); + let candidate = restrict_with_hints( + &original, + &[remove("privateNetworkClientServer")], + true, + &mut canonical, + ) + .unwrap(); + assert!(!candidate.policy.network_proxy.is_enabled()); + assert!(!candidate.policy.runtime_network_proxy_specified); + assert!(candidate.policy.capture_denials.is_none()); + assert!(!has_capability(&candidate, "internetClient", true)); + assert!(!has_capability( + &candidate, + "privateNetworkClientServer", + true + )); +} + +#[test] +fn restriction_proxy_removal_never_activates_hidden_direct_network_allowances() { + use crate::process_container_common::base_container_helpers::build_psec_v1_security_environment_spec; + use crate::process_security_environment_spec::process_security_environment_layout as psec; + + for capability in ["privateNetworkClientServer", "networkLoopback"] { + let original = request(ContainerPolicy { + network_egress: Some(NetworkEgressPolicy { + default: NetworkAction::Allow, + allow: vec![NetworkRule::default()], + deny: vec![NetworkRule::default()], + }), + network_ingress: Some(NetworkIngressPolicy { + default: NetworkAction::Allow, + host_loopback: NetworkAction::Allow, + }), + network_proxy: ProxyConfig { + address: Some(ProxyAddress::new("127.0.0.1".into(), 8080)), + }, + runtime_network_proxy_specified: true, + ..Default::default() + }); + let before = build_psec_v1_security_environment_spec( + &original, + SecurityEnvironmentVersion::V1_1, + false, + ); + let before = psec::root_as_process_security_environment(&before).unwrap(); + let before = before.network_policy().unwrap(); + assert!(before.proxy().is_some()); + assert!(before.egress().is_none()); + assert!(before.ingress().is_none()); + + let candidate = + restrict_with_hints(&original, &[remove(capability)], false, &mut canonical).unwrap(); + let after = build_psec_v1_security_environment_spec( + &candidate, + SecurityEnvironmentVersion::V1_1, + false, + ); + let after = psec::root_as_process_security_environment(&after).unwrap(); + let after = after.network_policy().unwrap(); + assert!(after.proxy().is_none()); + let egress = after.egress().unwrap(); + assert_eq!(egress.default_action(), psec::FilterAction::deny); + assert!(egress.allow().is_none()); + assert_eq!(egress.deny().unwrap().len(), 1); + assert!(after.ingress().is_none()); + assert_eq!( + candidate.policy.network_ingress, + Some(NetworkIngressPolicy::default()) + ); + assert!(after.allowed_appcontainer_peer().is_none()); + } +} + +#[test] +fn restriction_private_network_removal_preserves_unrelated_direct_capture() { + let original = request(ContainerPolicy { + capabilities: vec!["learningModeLogging".into()], + network_egress: Some(NetworkEgressPolicy::default()), + network_ingress: Some(NetworkIngressPolicy { + default: NetworkAction::Allow, + host_loopback: NetworkAction::Deny, + }), + capture_denials: Some(CaptureDenialsConfig::default()), + ..Default::default() + }); + let candidate = restrict_with_hints( + &original, + &[remove("privateNetworkClientServer")], + true, + &mut canonical, + ) + .unwrap(); + assert!(candidate.policy.capture_denials.is_some()); + assert!(has_capability(&candidate, "internetClient", true)); + assert!(!has_capability( + &candidate, + "privateNetworkClientServer", + true + )); +} + +#[test] +fn restriction_restricts_derived_host_loopback_without_network_capture() { + let original = request(ContainerPolicy { + network_ingress: Some(NetworkIngressPolicy { + default: NetworkAction::Deny, + host_loopback: NetworkAction::Allow, + }), + ..Default::default() + }); + assert!(effective_capabilities(&original, false) + .iter() + .any(|capability| { capability.eq_ignore_ascii_case("networkLoopback") })); + let candidate = restrict_with_hints( + &original, + &[remove("networkLoopback")], + false, + &mut canonical, + ) + .unwrap(); + assert_eq!( + candidate + .policy + .network_ingress + .as_ref() + .unwrap() + .host_loopback, + NetworkAction::Deny, + ); + assert!(!effective_capabilities(&candidate, false) + .iter() + .any(|capability| capability.eq_ignore_ascii_case("networkLoopback"))); +} + +#[test] +fn restriction_can_restrict_permissive_capture_or_disable_reserved_logging() { + let original = request(ContainerPolicy { + capabilities: vec!["permissiveLearningMode".into(), "registryRead".into()], + capture_denials: Some(CaptureDenialsConfig { + mode: CaptureDenialsMode::Allow, + ..Default::default() + }), + ..Default::default() + }); + let blocked = restrict_with_hints( + &original, + &[remove("permissiveLearningMode")], + false, + &mut canonical, + ) + .unwrap(); + assert_eq!( + blocked.policy.capture_denials.as_ref().unwrap().mode, + CaptureDenialsMode::Block + ); + assert!(has_capability(&blocked, "learningModeLogging", false)); + assert!(!has_capability(&blocked, "permissiveLearningMode", false)); + let disabled = restrict_with_hints( + &blocked, + &[remove("learningModeLogging")], + false, + &mut canonical, + ) + .unwrap(); + assert!(disabled.policy.capture_denials.is_none()); + assert_eq!(disabled.policy.capabilities, ["registryRead"]); +} + +#[test] +fn restriction_moves_only_the_existing_writable_spelling_and_preserves_exceptions() { + let original = request(ContainerPolicy { + readwrite_paths: vec![r"C:\work".into(), r"C:\work\child".into()], + readonly_paths: vec![r"C:\work\read".into()], + denied_paths: vec![r"C:\work\secret".into()], + ..Default::default() + }); + let candidate = restrict_with_hints( + &original, + &[restrict(r"C:\WORK", 2, 1)], + false, + &mut canonical, + ) + .unwrap(); + assert_eq!(candidate.policy.readwrite_paths, [r"C:\work\child"]); + assert_eq!( + candidate.policy.readonly_paths, + [r"C:\work\read", r"C:\work"] + ); + assert_eq!(candidate.policy.denied_paths, original.policy.denied_paths); + for point in [ + r"C:\work", + r"C:\work\child", + r"C:\work\read", + r"C:\work\secret", + ] { + assert!(access_at(&candidate.policy, point) <= access_at(&original.policy, point)); + } + assert_eq!( + rejection( + &original, + &[restrict(r"C:\work\new", 2, 1)], + false, + &mut canonical + ), + READONLY_NEEDS_GRANT + ); +} + +#[test] +fn restriction_can_add_deny_but_cannot_remove_a_readonly_exception_under_a_write_grant() { + let original = request(ContainerPolicy { + readwrite_paths: vec![r"C:\work".into()], + readonly_paths: vec![r"C:\work\read".into()], + denied_paths: vec![r"C:\work\secret".into()], + ..Default::default() + }); + let candidate = restrict_with_hints( + &original, + &[restrict(r"C:\work\read", 1, 0)], + false, + &mut canonical, + ) + .unwrap(); + assert_eq!(access_at(&candidate.policy, r"C:\work\read\child"), 0); + assert!(candidate + .policy + .denied_paths + .contains(&r"C:\work\secret".into())); + assert_eq!( + candidate.policy.readwrite_paths, + original.policy.readwrite_paths + ); +} + +#[test] +fn restriction_rejects_ambiguous_filesystem_policy() { + let original = request(ContainerPolicy { + capabilities: vec!["registryRead".into()], + readwrite_paths: vec![r"C:\work".into()], + ..Default::default() + }); + for (path, message) in [ + (r"relative", ABSOLUTE_PATH), + (r"C:relative", ABSOLUTE_PATH), + (r"\\?\C:\work", ABSOLUTE_PATH), + (r"C:\work\*", ABSOLUTE_PATH), + (r"\\", NON_CANONICAL), + (r"\\\", NON_CANONICAL), + (r"\\\\", NON_CANONICAL), + (r"\\server", NON_CANONICAL), + (r"C:\work\..\other", NON_CANONICAL), + (r"C:\work\\child", NON_CANONICAL), + (r"C:\work\child.", NON_CANONICAL), + (r"C:\work:stream", NON_CANONICAL), + ] { + assert_eq!( + rejection( + &original, + &[remove("registryRead"), restrict(path, 2, 0)], + false, + &mut canonical, + ), + message, + "{path}" + ); + } + assert_eq!( + rejection(&original, &[restrict(r"C:\work", 2, 1)], false, &mut |_| { + Ok(PathCanonical::Canonical(r"C:\elsewhere".into())) + }), + "filesystem restriction will not guess the provenance of an aliased path" + ); + assert_eq!( + rejection(&original, &[restrict(r"C:\work", 2, 1)], false, &mut |_| { + Ok(PathCanonical::Unknown) + }), + "filesystem restriction cannot resolve every relevant path safely" + ); + let mut enumeration = original.clone(); + enumeration.policy.enumerate_paths.push(r"C:\index".into()); + let restricted = restrict_with_hints( + &enumeration, + &[restrict(r"C:\work", 2, 1)], + false, + &mut canonical, + ) + .expect("enumeration-only paths do not affect access restrictions"); + assert_eq!( + restricted.policy.enumerate_paths, + enumeration.policy.enumerate_paths + ); + for root in [r"C:\", r"\\server\share"] { + let root_request = request(ContainerPolicy { + readwrite_paths: vec![root.into()], + ..Default::default() + }); + assert_eq!( + rejection( + &root_request, + &[restrict(root, 2, 1)], + false, + &mut canonical + ), + ROOT_GRANT, + "{root}" + ); + } +} + +#[test] +fn restriction_path_count_only_deduplicates_exact_entries() { + let original = request(ContainerPolicy { + readwrite_paths: vec![r"C:\work".into(), r"C:\work".into()], + readonly_paths: vec![r"C:\work\read".into()], + denied_paths: vec![r"C:\work\secret".into(), r"C:\work\secret".into()], + enumerate_paths: vec![r"C:\index".into()], + ..Default::default() + }); + let hint = RestrictionHint::ReducePathCount { + requested: 5, + maximum: 3, + }; + let candidate = restrict_with_hints(&original, &[hint], false, &mut canonical).unwrap(); + assert_eq!(native_path_count(&candidate.policy), 3); + assert_eq!(candidate.policy.denied_paths, [r"C:\work\secret"]); + assert_eq!( + candidate.policy.enumerate_paths, + original.policy.enumerate_paths + ); + assert_eq!( + rejection( + &original, + &[RestrictionHint::ReducePathCount { + requested: 5, + maximum: 2 + }], + false, + &mut canonical, + ), + "the path limit cannot be met by exact deduplication; MXC will not discard distinct grants or exceptions" + ); + assert_eq!( + rejection( + &original, + &[RestrictionHint::ReducePathCount { + requested: 4, + maximum: 3 + }], + false, + &mut canonical, + ), + "the native path count disagrees with the submitted request" + ); +} + +#[test] +fn restriction_rejects_contradictory_and_unmatched_constraints() { + let original = request(ContainerPolicy { + readwrite_paths: vec![r"C:\work".into()], + ..Default::default() + }); + assert_eq!( + rejection( + &original, + &[restrict(r"C:\work", 2, 1), restrict(r"C:\work", 2, 0)], + false, + &mut canonical, + ), + "the native details contain contradictory constraints" + ); + assert_eq!( + rejection( + &original, + &[restrict(r"C:\work", 1, 0)], + false, + &mut canonical + ), + "the native filesystem witness disagrees with the submitted request" + ); + assert_eq!( + rejection(&original, &[remove("unknown")], false, &mut canonical), + "the forbidden capability does not match the submitted request" + ); +} diff --git a/src/mxc-sdk/src/backends/process_container/common/secenv.rs b/src/mxc-sdk/src/backends/process_container/common/secenv.rs index 4d76514088..f5121b6d38 100644 --- a/src/mxc-sdk/src/backends/process_container/common/secenv.rs +++ b/src/mxc-sdk/src/backends/process_container/common/secenv.rs @@ -36,6 +36,9 @@ use std::ptr; use std::sync::OnceLock; use crate::mxc_common::api_set::is_api_set_implemented; +use crate::mxc_common::models::{FailurePhase, ScriptResponse}; +use crate::mxc_common::mxc_error::{ApiFailure, MxcError}; +use crate::mxc_common::policy_failures::{PolicyFailureResult, PolicyFailures}; use crate::mxc_common::string_util; use windows::Win32::Foundation::{GetLastError, ERROR_INSUFFICIENT_BUFFER, HANDLE, HMODULE}; use windows::Win32::System::LibraryLoader::{ @@ -48,6 +51,9 @@ use windows::Win32::System::Threading::{ use windows_core::{HRESULT, PCSTR, PCWSTR}; use crate::learning_mode_windows::LearningModeError; +use crate::process_container_common::secenv_policy::{ + PolicyResult, MAX_RESULT_BYTES, POLICY_RESULT_VERSION, +}; /// System DLL that hosts the flat process security-environment exports. const PROCESSMODEL_DLL: &str = "processmodel.dll"; @@ -93,6 +99,8 @@ impl SecurityEnvironmentSupport { /// declared here: explicit [`ProcessSecurityEnvironment::close`] after the child /// has exited already provides deterministic teardown. pub const PROCESS_SECURITY_ENVIRONMENT_FLAG_NONE: u32 = 0; +const EXPERIMENTAL_PROCESS_SECURITY_ENVIRONMENT_FLAG_CAPTURE_POLICY_RESULT: u32 = 0x2; +const EXPERIMENTAL_PSE_SUPPORT_POLICY_RESULT: u64 = 0x10; /// `HRESULT CreateProcessSecurityEnvironment(LPCVOID sandboxSpecification, /// DWORD sandboxSpecificationSize, PROCESS_SECURITY_ENVIRONMENT_FLAGS flags, @@ -106,10 +114,120 @@ type PfnCreateProcessSecurityEnvironment = unsafe extern "system" fn( process_security_environment: *mut HANDLE, ) -> HRESULT; +type PfnGetLastProcessSecurityEnvironmentPolicyResult = unsafe extern "system" fn( + requested_version: u32, + policy_result_size_bytes: *mut u32, + policy_result: *mut *mut u8, +) -> HRESULT; + /// `HRESULT QueryProcessSecurityEnvironmentSupport(UINT64* supportFlags)`. type PfnQueryProcessSecurityEnvironmentSupport = unsafe extern "system" fn(support_flags: *mut u64) -> HRESULT; +pub(crate) struct CreationError { + cause: LearningModeError, + diagnostic: Option, + policy_result: Option>, +} + +/// Debug output omits the policy result, which can hold policy resource names. +impl std::fmt::Debug for CreationError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("CreationError") + .field("cause", &self.cause) + .field("diagnostic", &self.diagnostic) + .field( + "policy_result", + &self.policy_result.as_ref().map(|_| ""), + ) + .finish() + } +} + +impl From for CreationError { + fn from(cause: LearningModeError) -> Self { + Self { + cause, + diagnostic: None, + policy_result: None, + } + } +} + +impl CreationError { + pub(crate) fn is_api_unavailable(&self) -> bool { + self.cause.is_api_unavailable() + } + + pub(crate) fn message(&self, capture: bool) -> String { + crate::process_container_common::launch_diagnostics::security_environment_failure_message( + &self.cause, + capture, + ) + } + + pub(crate) fn response(&self, capture: bool) -> ScriptResponse { + let phase = if self.is_api_unavailable() { + FailurePhase::BackendUnavailable + } else { + FailurePhase::LaunchFailed + }; + let mut error = MxcError::new(phase.error_code(), self.message(capture)); + if let LearningModeError::HResultCall { function, code } = &self.cause { + error = error.with_api_failure( + ApiFailure::new(*function).with_native_code(format!("0x{:08X}", *code as u32)), + ); + if *function == "CreateProcessSecurityEnvironment" + && *code + == windows::Win32::Foundation::ERROR_ACCESS_DISABLED_BY_POLICY + .to_hresult() + .0 + { + let mut failures = PolicyFailures::default(); + if let Some(result) = &self.policy_result { + failures.append((**result).clone()); + } + let mut details = serde_json::Map::new(); + if !failures.results.is_empty() { + if let Err(reason) = failures.validate() { + details.insert( + "policyFeedbackError".into(), + serde_json::json!({ + "code": "feedbackLimitExceeded", + "reason": reason, + }), + ); + } + details.insert("policyFailures".into(), serde_json::json!(failures)); + } + if let Some(diagnostic) = &self.diagnostic { + details.insert( + "policyResultUnavailable".into(), + serde_json::Value::String(diagnostic.clone()), + ); + } + if !details.is_empty() { + error = error.with_details(serde_json::Value::Object(details)); + } + } + } + ScriptResponse::failed(phase, &error.message).with_error(error) + } +} + +impl std::fmt::Display for CreationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.cause.fmt(f) + } +} + +impl std::error::Error for CreationError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.cause) + } +} + /// `HRESULT IsProcessSecurityEnvironmentVersionSupported( /// DWORD major, BOOLEAN* available, DWORD* minor)`. type PfnIsProcessSecurityEnvironmentVersionSupported = @@ -299,6 +417,11 @@ impl SecurityEnvironmentStartupInfo { pub fn startup_info(&self) -> &STARTUPINFOEXW { &self.startup_info } + + #[cfg(test)] + pub(crate) fn environment_for_test(&self) -> HANDLE { + *self.environment_value + } } impl std::fmt::Debug for SecurityEnvironmentStartupInfo { @@ -345,6 +468,8 @@ impl SecurityEnvironmentExportReport { } const CREATE_NAMES: &[&core::ffi::CStr] = &[c"CreateProcessSecurityEnvironment"]; +const POLICY_RESULT_NAME: &core::ffi::CStr = + c"Experimental_GetLastProcessSecurityEnvironmentPolicyResult"; const QUERY_SUPPORT_NAMES: &[&core::ffi::CStr] = &[c"QueryProcessSecurityEnvironmentSupport"]; const VERSION_SUPPORT_NAMES: &[&core::ffi::CStr] = &[c"IsProcessSecurityEnvironmentVersionSupported"]; @@ -360,6 +485,7 @@ const CLOSE_NAMES: &[&core::ffi::CStr] = &[c"CloseProcessSecurityEnvironment"]; #[derive(Clone, Copy)] pub struct SecurityEnvironmentApi { create: PfnCreateProcessSecurityEnvironment, + get_last_policy_result: Option, query_support: PfnQueryProcessSecurityEnvironmentSupport, version_support: Option, close: PfnCloseProcessSecurityEnvironment, @@ -397,6 +523,10 @@ impl SecurityEnvironmentApi { /// - [`LearningModeError::DllLoad`] if `processmodel.dll` cannot be loaded. /// - [`LearningModeError::ExportMissing`] if any required export is absent. pub fn load() -> Result { + #[cfg(test)] + if let Some(api) = policy_diagnostics_tests::injected_api() { + return Ok(api); + } static CACHE: OnceLock> = OnceLock::new(); CACHE.get_or_init(Self::load_uncached).clone() } @@ -441,6 +571,16 @@ impl SecurityEnvironmentApi { unsafe extern "system" fn() -> isize, PfnCreateProcessSecurityEnvironment, >(create_proc), + get_last_policy_result: GetProcAddress( + hmodule, + PCSTR(POLICY_RESULT_NAME.as_ptr().cast()), + ) + .map(|function| { + std::mem::transmute::< + unsafe extern "system" fn() -> isize, + PfnGetLastProcessSecurityEnvironmentPolicyResult, + >(function) + }), query_support: std::mem::transmute::< unsafe extern "system" fn() -> isize, PfnQueryProcessSecurityEnvironmentSupport, @@ -466,6 +606,7 @@ impl SecurityEnvironmentApi { ) -> Self { Self { create, + get_last_policy_result: None, query_support, version_support: None, close, @@ -483,6 +624,7 @@ impl SecurityEnvironmentApi { ) -> Self { Self { create, + get_last_policy_result: None, query_support, version_support: None, close, @@ -552,6 +694,25 @@ impl SecurityEnvironmentApi { Ok(support_flags) } + pub(crate) fn create_with_diagnostics( + &self, + specification: &[u8], + flags: u32, + ) -> Result { + let get_last_policy_result = match self.get_last_policy_result { + Some(get_result) => self.support_flags().map(|support| { + (support & EXPERIMENTAL_PSE_SUPPORT_POLICY_RESULT != 0).then_some(get_result) + }), + None => Ok(None), + }; + let flags = if matches!(&get_last_policy_result, Ok(Some(_))) { + flags | EXPERIMENTAL_PROCESS_SECURITY_ENVIRONMENT_FLAG_CAPTURE_POLICY_RESULT + } else { + flags + }; + self.create_internal(specification, flags, get_last_policy_result) + } + /// Create a process security environment from a PSEC FlatBuffer /// blob. `flags` is currently always [`PROCESS_SECURITY_ENVIRONMENT_FLAG_NONE`]. /// @@ -562,6 +723,19 @@ impl SecurityEnvironmentApi { sandbox_specification: &[u8], flags: u32, ) -> Result { + self.create_internal(sandbox_specification, flags, Ok(None)) + .map_err(|error| error.cause) + } + + fn create_internal( + &self, + sandbox_specification: &[u8], + flags: u32, + get_last_policy_result: Result< + Option, + LearningModeError, + >, + ) -> Result { let mut env = HANDLE(ptr::null_mut()); let spec_len = u32::try_from(sandbox_specification.len()).map_err(|_| { LearningModeError::HResultCall { @@ -582,25 +756,107 @@ impl SecurityEnvironmentApi { &mut env, ) }; + let environment = (!env.0.is_null()).then_some(ProcessSecurityEnvironment { + handle: env, + close: self.close, + }); if result.is_err() { - return Err(LearningModeError::HResultCall { + let mut error = CreationError::from(LearningModeError::HResultCall { function: "CreateProcessSecurityEnvironment", code: result.0, }); + if result == windows::Win32::Foundation::ERROR_ACCESS_DISABLED_BY_POLICY.to_hresult() { + match get_last_policy_result { + Ok(Some(get_result)) => { + // The snapshot belongs to this native thread. Retrieve it before + // logging, callbacks, another CPSE call, or environment cleanup. + let (result, diagnostic) = policy_failure_diagnostic(get_result); + error.policy_result = result.map(Box::new); + error.diagnostic = diagnostic; + } + Err(capture_error) => { + error.diagnostic = Some(format!( + "Creation-policy diagnostics unavailable: {capture_error}." + )); + } + Ok(None) => { + error.diagnostic = Some( + "Creation-policy result reporting is unavailable on this host.".into(), + ); + } + } + } + return Err(error); } - if env.0.is_null() { - return Err(LearningModeError::HResultCall { + environment.ok_or_else(|| { + CreationError::from(LearningModeError::HResultCall { function: "CreateProcessSecurityEnvironment", code: windows::Win32::Foundation::E_UNEXPECTED.0, - }); - } - Ok(ProcessSecurityEnvironment { - handle: env, - close: self.close, + }) }) } } +fn policy_failure_diagnostic( + get_result: PfnGetLastProcessSecurityEnvironmentPolicyResult, +) -> (Option, Option) { + let mut buffer = PolicyResultBuffer(ptr::null_mut()); + let mut size = 0u32; + // SAFETY: the current getter writes a byte count and CoTaskMem-owned pointer + // into valid output locations on the failed creation's native thread. + let status = unsafe { get_result(POLICY_RESULT_VERSION, &mut size, &mut buffer.0) }; + if status.is_err() { + let error = LearningModeError::HResultCall { + function: "Experimental_GetLastProcessSecurityEnvironmentPolicyResult", + code: status.0, + }; + let reason = if status == windows::Win32::Foundation::ERROR_NOT_FOUND.to_hresult() { + "Windows retained no policy result for this creation attempt. " + } else { + "" + }; + return ( + None, + Some(format!( + "Creation-policy diagnostics unavailable: {reason}{error}." + )), + ); + } + let decoded = buffer.bytes(size as usize).and_then(PolicyResult::decode); + match decoded { + Ok(result) => (Some(result.into()), None), + Err(error) => ( + None, + Some(format!("Creation-policy diagnostics unavailable: {error}.")), + ), + } +} + +struct PolicyResultBuffer(*mut u8); + +impl PolicyResultBuffer { + fn bytes(&self, size: usize) -> Result<&[u8], &'static str> { + if self.0.is_null() || size == 0 || size > MAX_RESULT_BYTES { + return Err("native result has a null buffer or invalid byte count"); + } + // SAFETY: the getter returns a readable allocation of its reported size. + // The size is bounded and this owner remains live through decoding. + Ok(unsafe { std::slice::from_raw_parts(self.0, size) }) + } +} + +impl Drop for PolicyResultBuffer { + fn drop(&mut self) { + if !self.0.is_null() { + // SAFETY: ownership transfers from the getter exactly once, including + // unexpected non-null outputs on failure or invalid serialized data. + unsafe { windows::Win32::System::Com::CoTaskMemFree(Some(self.0.cast())) }; + #[cfg(test)] + policy_diagnostics_tests::policy_buffer_freed(self.0); + } + } +} + /// Whether the process security-environment API supports `capability`. /// /// API loading, contract-version validation, and query failures all fail closed. @@ -635,6 +891,13 @@ pub(crate) fn create( SecurityEnvironmentApi::load()?.create(sandbox_specification, flags) } +pub(crate) fn create_with_diagnostics( + specification: &[u8], + flags: u32, +) -> Result { + SecurityEnvironmentApi::load()?.create_with_diagnostics(specification, flags) +} + fn query_supported_minor_version_with( major: u32, version_support: PfnIsProcessSecurityEnvironmentVersionSupported, @@ -741,6 +1004,16 @@ pub fn is_security_environment_api_available() -> bool { probe_security_environment_exports().is_complete() } +#[cfg(test)] +#[path = "secenv_policy_tests.rs"] +mod policy_diagnostics_tests; + +#[cfg(test)] +pub(super) fn injected_capture_trace_sources( +) -> Option { + policy_diagnostics_tests::injected_capture_trace_sources() +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/mxc-sdk/src/backends/process_container/common/secenv_policy.rs b/src/mxc-sdk/src/backends/process_container/common/secenv_policy.rs new file mode 100644 index 0000000000..805470f3e3 --- /dev/null +++ b/src/mxc-sdk/src/backends/process_container/common/secenv_policy.rs @@ -0,0 +1,213 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Bounded native result decoding and recognition of restrictive instructions. + +use crate::core_modules::process_security_environment_policy_result::experimental_process_security_environment_policy_result as layout; +use crate::mxc_common::policy_failures::{PolicyFailureDetail, PolicyFailureResult}; + +pub(crate) use layout::{PolicyDetailT as PolicyDetail, PolicyResultT as PolicyResult}; + +pub(crate) const POLICY_RESULT_VERSION: u32 = layout::PolicyResultVersion::V1.0; +pub(crate) const MAX_RESULT_BYTES: usize = layout::PolicyResultLimit::MaxResultBytes.0 as usize; +const MAX_DETAILS: usize = layout::PolicyResultLimit::MaxDetails.0 as usize; +const MAX_RESOURCE_CHARS: usize = layout::PolicyResultLimit::MaxResourceChars.0 as usize; +const ACTIONABLE: u32 = layout::PolicyResultFlags::HasActionableDetails.0; +const RESOURCE_COMPLETE: u32 = layout::PolicyResultFlags::ResourceComplete.0; +const RESOURCE_UNAVAILABLE: u32 = layout::PolicyResultFlags::ResourceUnavailable.0; +const KNOWN_FLAGS: u32 = ACTIONABLE | RESOURCE_COMPLETE | RESOURCE_UNAVAILABLE; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) enum RestrictionHint { + RemoveCapability(String), + RestrictPath { + path: String, + requested: u64, + maximum: u64, + }, + ReducePathCount { + requested: u64, + maximum: u64, + }, +} + +impl PolicyResult { + pub(crate) fn decode(bytes: &[u8]) -> Result { + if bytes.len() < 8 || bytes.len() > MAX_RESULT_BYTES { + return Err("native result has an invalid byte count"); + } + if !layout::policy_result_buffer_has_identifier(bytes) { + return Err("native result is not a PSEP FlatBuffer"); + } + let options = flatbuffers::VerifierOptions { + max_depth: 16, + max_tables: 256, + max_apparent_size: MAX_RESULT_BYTES, + ..Default::default() + }; + let result = layout::root_as_policy_result_with_opts(&options, bytes) + .map_err(|_| "native result is a malformed PSEP FlatBuffer")?; + if result.version() != POLICY_RESULT_VERSION + || result.minimum_reader_version() != POLICY_RESULT_VERSION + { + return Err("native result does not match the requested V1 contract"); + } + if result + .details() + .is_some_and(|details| details.len() > MAX_DETAILS) + { + return Err("native result exceeded the detail limit"); + } + let mut resource_chars = 0usize; + if let Some(details) = result.details() { + for detail in details { + if let Some(resource) = detail.resource() { + resource_chars = resource_chars.saturating_add(resource.len() + 1); + if resource_chars > MAX_RESOURCE_CHARS { + return Err("native result exceeded the resource limit"); + } + if detail.flags() & RESOURCE_COMPLETE == 0 + || detail.flags() & RESOURCE_UNAVAILABLE != 0 + { + return Err("native result has inconsistent resource metadata"); + } + } + } + } + Ok(result.unpack()) + } + + pub(crate) fn restriction_hints(&self) -> Option> { + let details = self.details.as_deref().unwrap_or_default(); + if self.outcome.0 != 1 || details.is_empty() { + return None; + } + details + .iter() + .map(|detail| { + if detail.flags & ACTIONABLE == 0 || detail.flags & !KNOWN_FLAGS != 0 { + return None; + } + match ( + detail.failure_class.0, + detail.failure_reason.0, + detail.required_action.0, + detail.resource_kind.0, + detail.value_kind.0, + ) { + (1..=3, 1, 1, 2, 0) + if detail.flags == ACTIONABLE | RESOURCE_COMPLETE + && detail.requested_value == 0 + && detail.required_value == 0 => + { + Some(RestrictionHint::RemoveCapability( + detail.resource_text().ok()??, + )) + } + (2, 2 | 3, 2, 1, 1) + if detail.flags == ACTIONABLE | RESOURCE_COMPLETE + && detail.requested_value <= 2 + && detail.required_value < detail.requested_value + && (detail.failure_reason.0 != 3 || detail.required_value == 0) => + { + Some(RestrictionHint::RestrictPath { + path: detail.resource_text().ok()??, + requested: detail.requested_value, + maximum: detail.required_value, + }) + } + (2, 4, 3, 0, 2) + if detail.flags == ACTIONABLE + && detail.requested_value > detail.required_value + && detail.resource.is_none() => + { + Some(RestrictionHint::ReducePathCount { + requested: detail.requested_value, + maximum: detail.required_value, + }) + } + _ => None, + } + }) + .collect() + } +} + +impl PolicyDetail { + fn resource_text(&self) -> Result, &'static str> { + if self.flags & RESOURCE_COMPLETE == 0 { + return if self.resource.is_some() { + Err("incomplete resource metadata") + } else { + Ok(None) + }; + } + let resource = self + .resource + .as_deref() + .ok_or("missing complete resource")?; + if self.flags & RESOURCE_UNAVAILABLE != 0 + || self.resource_kind.0 == 0 + || resource.is_empty() + || resource.contains(&0) + { + return Err("inconsistent complete-resource metadata"); + } + String::from_utf16(resource) + .map(Some) + .map_err(|_| "invalid UTF-16") + } +} + +impl From for PolicyFailureResult { + fn from(result: PolicyResult) -> Self { + Self { + version: result.version, + minimum_reader_version: result.minimum_reader_version, + outcome: result.outcome.0, + details: result + .details + .unwrap_or_default() + .into_iter() + .map(|detail| PolicyFailureDetail { + failure_class: detail.failure_class.0, + failure_reason: detail.failure_reason.0, + required_action: detail.required_action.0, + resource_kind: detail.resource_kind.0, + value_kind: detail.value_kind.0, + flags: detail.flags, + requested_value: detail.requested_value, + required_value: detail.required_value, + resource: detail.resource, + }) + .collect(), + } + } +} + +impl From<&PolicyFailureResult> for PolicyResult { + fn from(result: &PolicyFailureResult) -> Self { + Self { + version: result.version, + minimum_reader_version: result.minimum_reader_version, + outcome: layout::PolicyOutcome(result.outcome), + details: Some( + result + .details + .iter() + .map(|detail| PolicyDetail { + failure_class: layout::FailureClass(detail.failure_class), + failure_reason: layout::FailureReason(detail.failure_reason), + required_action: layout::RequiredAction(detail.required_action), + resource_kind: layout::ResourceKind(detail.resource_kind), + value_kind: layout::ValueKind(detail.value_kind), + flags: detail.flags, + requested_value: detail.requested_value, + required_value: detail.required_value, + resource: detail.resource.clone(), + }) + .collect(), + ), + } + } +} diff --git a/src/mxc-sdk/src/backends/process_container/common/secenv_policy_tests.rs b/src/mxc-sdk/src/backends/process_container/common/secenv_policy_tests.rs new file mode 100644 index 0000000000..70626d18af --- /dev/null +++ b/src/mxc-sdk/src/backends/process_container/common/secenv_policy_tests.rs @@ -0,0 +1,812 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::*; +use crate::core_modules::process_security_environment_policy_result::experimental_process_security_environment_policy_result as layout; +use crate::mxc_common::policy_failures::PolicyFailureDetail; +use std::cell::{Cell, RefCell}; +use windows::Win32::Foundation::{E_FAIL, S_OK}; +use windows::Win32::System::Com::CoTaskMemAlloc; + +#[path = "policy_preparation_backend_tests.rs"] +mod preparation; + +const POLICY_DENIED: HRESULT = HRESULT(0x800704ecu32 as i32); +const POLICY_DENIED_MESSAGE: &str = concat!( + "failed to create the process security environment: ", + "CreateProcessSecurityEnvironment failed (HRESULT = 0x800704EC). ", + "Windows blocked creation of the requested sandbox because of an IT-managed policy rule ", + "(ERROR_ACCESS_DISABLED_BY_POLICY, 1260). Review the requested sandbox permissions or ", + "contact your system administrator.", +); + +struct FakeState { + status: HRESULT, + handle: bool, + support_status: HRESULT, + support_flags: u64, + getter_status: HRESULT, + report: PolicyFailureResult, + payload_override: Option>, + reported_size: Option, + flags: Vec, + specifications: Vec>, + calls: Vec<&'static str>, + threads: Vec, + frees: usize, + closes: usize, + capture_sources: crate::learning_mode_windows::LearningModeTraceSources, +} + +impl Default for FakeState { + fn default() -> Self { + Self { + status: POLICY_DENIED, + handle: false, + support_status: S_OK, + support_flags: 0x10, + getter_status: S_OK, + report: PolicyFailureResult { + outcome: 1, + details: vec![PolicyFailureDetail { + failure_class: 2, + failure_reason: 2, + required_action: 2, + resource_kind: 1, + value_kind: 1, + flags: 3, + requested_value: 2, + required_value: 1, + resource: Some(r"C:\work".encode_utf16().collect()), + }], + ..Default::default() + }, + payload_override: None, + reported_size: None, + flags: Vec::new(), + specifications: Vec::new(), + calls: Vec::new(), + threads: Vec::new(), + frees: 0, + closes: 0, + capture_sources: crate::learning_mode_windows::LearningModeTraceSources::AccessOnly, + } + } +} + +thread_local! { + static STATE: RefCell = RefCell::new(FakeState::default()); + static INJECTED_API: Cell> = const { Cell::new(None) }; +} + +pub(super) fn injected_api() -> Option { + INJECTED_API.with(Cell::get) +} + +pub(super) fn injected_capture_trace_sources( +) -> Option { + injected_api().map(|_| STATE.with(|state| state.borrow().capture_sources)) +} + +pub(super) fn policy_buffer_freed(_: *mut u8) { + STATE.with(|state| state.borrow_mut().frees += 1); +} + +fn reset() -> SecurityEnvironmentApi { + STATE.with(|state| *state.borrow_mut() = FakeState::default()); + let mut api = SecurityEnvironmentApi::from_raw_parts(create, query, close); + api.get_last_policy_result = Some(get_result); + api +} + +unsafe extern "system" fn query(flags: *mut u64) -> HRESULT { + STATE.with(|state| { + let mut state = state.borrow_mut(); + state.calls.push("support"); + // SAFETY: the tested API passes valid output storage. + unsafe { *flags = state.support_flags }; + state.support_status + }) +} + +unsafe extern "system" fn create( + specification: *const c_void, + size: u32, + flags: u32, + output: *mut HANDLE, +) -> HRESULT { + STATE.with(|state| { + let mut state = state.borrow_mut(); + state.calls.push("create"); + state.threads.push(std::thread::current().id()); + state.flags.push(flags); + // SAFETY: the tested API supplies the complete input buffer. + state.specifications.push(unsafe { + std::slice::from_raw_parts(specification.cast::(), size as usize).to_vec() + }); + if state.handle { + // SAFETY: fake output storage is provided by the tested API. + unsafe { *output = HANDLE(0x1000usize as *mut c_void) }; + } + state.status + }) +} + +struct ApiScope(Option); + +impl Drop for ApiScope { + fn drop(&mut self) { + INJECTED_API.with(|slot| slot.set(self.0)); + } +} + +unsafe extern "system" fn version(_: u32, available: *mut u8, minor: *mut u32) -> HRESULT { + // SAFETY: native version-query outputs are valid for this fake. + unsafe { + *available = 1; + *minor = 3; + } + S_OK +} + +struct UnusedCapture; + +impl crate::process_container_common::base_container_runner::CaptureSessionFactory + for UnusedCapture +{ + fn trace_sources( + &self, + ) -> Result { + Ok(crate::learning_mode_windows::LearningModeTraceSources::AccessOnly) + } + + fn begin( + &self, + _: &[u8], + _: u32, + ) -> Result< + Box, + CreationError, + > { + Err(LearningModeError::HResultCall { + function: "unexpected test capture", + code: E_FAIL.0, + } + .into()) + } +} + +fn launch_fixed_fixture( + launch: crate::process_container_common::base_container_runner::BaseProcessLaunch<'_>, +) -> windows_core::Result<()> { + use windows::Win32::System::Threading::{ + CreateProcessW, CREATE_NO_WINDOW, EXTENDED_STARTUPINFO_PRESENT, PROCESS_CREATION_FLAGS, + STARTUPINFOW, + }; + use windows_core::{PCWSTR, PWSTR}; + assert_eq!(launch.startup.environment_for_test().0 as usize, 0x1000); + assert_ne!(launch.creation_flags.0 & CREATE_NO_WINDOW.0, 0); + assert_eq!( + String::from_utf16(launch.command_line) + .unwrap() + .trim_end_matches('\0'), + "cmd.exe /d /c echo mxc-policy-child", + ); + STATE.with(|state| state.borrow_mut().calls.push("launch")); + let mut startup = launch.startup.startup_info().StartupInfo; + startup.cb = std::mem::size_of::() as u32; + // The native environment is fake. Execute only the fixed trusted fixture, + // without the fake PSEC attribute, retaining pipes/suspension/job cleanup. + unsafe { + CreateProcessW( + PCWSTR::null(), + Some(PWSTR(launch.command_line.as_mut_ptr())), + None, + None, + launch.inherit_handles, + PROCESS_CREATION_FLAGS(launch.creation_flags.0 & !EXTENDED_STARTUPINFO_PRESENT.0), + launch + .environment + .map(|block| block.as_ptr().cast::()), + PCWSTR(launch.working_directory.as_ptr()), + &startup, + launch.process_information, + ) + } +} + +fn parsed_request(json: &str) -> crate::mxc_common::models::ExecutionRequest { + use crate::mxc_common::logger::{Logger, Mode}; + use crate::mxc_common::state_aware_request::MxcRequest; + match crate::mxc_common::config_parser::load_mxc_request_from_json( + json, + &mut Logger::new(Mode::Buffer), + ) + .unwrap() + { + MxcRequest::OneShot(request) => request, + MxcRequest::StateAware(_) => panic!("expected a create-and-run request"), + } +} + +fn prepare_candidate( + original: &str, + failures: &PolicyFailures, +) -> crate::v1::dev::PreparedPolicyRequest { + fn resource_calls(state: &FakeState) -> Vec<&'static str> { + state + .calls + .iter() + .copied() + .filter(|call| matches!(*call, "create" | "get" | "launch" | "close")) + .collect() + } + let before = STATE.with(|state| { + let state = state.borrow(); + ( + state.specifications.len(), + resource_calls(&state), + state.frees, + state.closes, + ) + }); + let prepared = crate::v1::dev::prepare_policy_json(original, failures, Default::default()) + .expect("prepare without creation or workload execution"); + STATE.with(|state| { + let state = state.borrow(); + assert_eq!(state.specifications.len(), before.0); + assert_eq!(resource_calls(&state), before.1); + assert_eq!(state.frees, before.2); + assert_eq!(state.closes, before.3); + }); + assert_eq!( + crate::v1::dev::PreparedPolicyRequest::from_json(&prepared.to_json().unwrap()).unwrap(), + prepared, + ); + prepared +} + +#[test] +fn policy_preparation_app_loop_merges_observations_then_accepts_strict_creation() { + use crate::mxc_common::logger::{Logger, Mode}; + use crate::mxc_common::sandbox_process::{SandboxBackend, StdioMode}; + use crate::process_container_common::base_container_runner::BaseContainerRunner; + use crate::process_container_common::test_env::CaptureCapabilityGuard; + use crate::v1::dev::PolicyChangeOperation; + use std::io::Read; + use std::sync::Arc; + + let _host = CaptureCapabilityGuard::set(true, true); + let mut api = reset(); + api.version_support = Some(version); + let _scope = ApiScope(INJECTED_API.with(|slot| slot.replace(Some(api)))); + let directory = tempfile::tempdir().unwrap(); + let original = serde_json::json!({ + "version": "1.0.0", + "containerId": "policy-preparation-fixture", + "containment": "processcontainer", + "process": { + "commandLine": "cmd.exe /d /c echo mxc-policy-child", + "cwd": directory.path(), "timeout": 5000, + }, + "processContainer": { "capabilities": ["registryRead", "registryWrite"] }, + }) + .to_string(); + let mut selected = original.clone(); + let mut runner = BaseContainerRunner::with_capture_factory(Arc::new(UnusedCapture)) + .with_process_launcher(launch_fixed_fixture); + let mut failures = PolicyFailures::default(); + for (index, capability) in ["registryRead", "registryWrite"].into_iter().enumerate() { + STATE.with(|state| { + let mut state = state.borrow_mut(); + state.report.details = vec![PolicyFailureDetail { + failure_class: 2, + failure_reason: 1, + required_action: 1, + resource_kind: 2, + flags: 3, + resource: Some(capability.encode_utf16().collect()), + ..Default::default() + }]; + }); + let request = parsed_request(&selected); + let error = match runner.spawn(&request, &mut Logger::new(Mode::Buffer), StdioMode::Pipes) { + Err(error) => error, + Ok(_) => panic!("a refusal must not launch a workload"), + }; + let error = crate::mxc_engine::Error::from(error.into_mxc_error()); + let next = PolicyFailures::from_error(&error).unwrap().unwrap(); + assert_eq!( + next.results.len(), + 1, + "strict creation returns only its own observation" + ); + assert_eq!( + error.native_code.as_deref(), + Some(PolicyFailures::NATIVE_CODE_POLICY_BLOCKED) + ); + failures = failures.merged_with(&next).unwrap(); + assert_eq!(failures.results.len(), index + 1); + STATE.with(|state| { + let state = state.borrow(); + assert_eq!(state.specifications.len(), index + 1); + assert!(!state.calls.contains(&"launch")); + }); + let prepared = prepare_candidate(&original, &failures); + assert_eq!(prepared.changes.len(), 1); + let change = &prepared.changes[0]; + assert_eq!(change.operation, PolicyChangeOperation::Replace); + assert_eq!(change.path, "/processContainer/capabilities"); + assert_eq!( + change.before, + serde_json::json!(["registryRead", "registryWrite"]) + ); + assert_eq!( + change.after, + if index == 0 { + serde_json::json!(["registryWrite"]) + } else { + serde_json::json!([]) + }, + ); + selected = prepared.request_json; // The caller explicitly accepts the checked capability removals. + } + let request = parsed_request(&selected); + STATE.with(|state| { + let mut state = state.borrow_mut(); + state.status = S_OK; + state.handle = true; + }); + let mut process = runner + .spawn(&request, &mut Logger::new(Mode::Buffer), StdioMode::Pipes) + .expect("submit the accepted candidate through ordinary strict creation"); + let mut stdout = String::new(); + process + .take_stdout() + .unwrap() + .read_to_string(&mut stdout) + .unwrap(); + assert_eq!(stdout.trim(), "mxc-policy-child"); + assert_eq!(process.wait().unwrap(), 0); + assert!(process.warnings().is_empty()); + drop(process); + STATE.with(|state| { + let state = state.borrow(); + assert_eq!(state.specifications.len(), 3); + assert_eq!( + state.calls.iter().filter(|call| **call == "launch").count(), + 1 + ); + assert_eq!(state.closes, 1); + assert_eq!(state.frees, 2); + use crate::core_modules::process_security_environment_spec::process_security_environment_layout as psec; + for (index, bytes) in state.specifications.iter().enumerate() { + let spec = psec::root_as_process_security_environment(bytes).unwrap(); + let expected = ["registryRead,registryWrite", "registryWrite"].get(index); + assert_eq!(spec.capabilities(), expected.copied()); + } + }); +} + +unsafe extern "system" fn close(_: HANDLE) { + STATE.with(|state| { + let mut state = state.borrow_mut(); + state.calls.push("close"); + state.closes += 1; + }); +} + +unsafe extern "system" fn get_result( + version: u32, + size: *mut u32, + output: *mut *mut u8, +) -> HRESULT { + assert_eq!(version, POLICY_RESULT_VERSION); + STATE.with(|state| { + let mut state = state.borrow_mut(); + state.calls.push("get"); + state.threads.push(std::thread::current().id()); + let native = PolicyResult::from(&state.report); + let mut builder = flatbuffers::FlatBufferBuilder::new(); + let root = native.pack(&mut builder); + layout::finish_policy_result_buffer(&mut builder, root); + let bytes = state + .payload_override + .as_deref() + .unwrap_or(builder.finished_data()); + // SAFETY: this fake implements the native owned-allocation contract. + let allocation = unsafe { CoTaskMemAlloc(bytes.len()) }.cast::(); + assert!(!allocation.is_null()); + unsafe { + std::ptr::copy_nonoverlapping(bytes.as_ptr(), allocation, bytes.len()); + *output = allocation; + *size = state.reported_size.unwrap_or(bytes.len() as u32); + } + state.getter_status + }) +} + +#[test] +fn policy_failures_native_invalid_payloads_stop_and_free_without_text_specifics() { + for fault in [ + "identifier", + "table", + "version", + "resourceFlags", + "byteLimit", + "resourceLimit", + "zeroSize", + ] { + let api = reset(); + STATE.with(|state| { + let mut state = state.borrow_mut(); + match fault { + "identifier" => state.payload_override = Some(vec![0; 8]), + "table" => { + let mut bytes = vec![0; 8]; + bytes[4..].copy_from_slice(b"PSEP"); + state.payload_override = Some(bytes); + } + "version" => state.report.minimum_reader_version = 2, + "resourceFlags" => state.report.details[0].flags = 1, + "byteLimit" => state.reported_size = Some(MAX_RESULT_BYTES as u32 + 1), + "resourceLimit" => state.report.details[0].resource = Some(vec![65; 32_768]), + "zeroSize" => state.reported_size = Some(0), + _ => unreachable!(), + } + }); + let failure = api.create_with_diagnostics(b"spec", 0).unwrap_err(); + let error = crate::mxc_engine::Error::from(failure.response(false).into_mxc_error()); + assert!( + PolicyFailures::from_error(&error).unwrap().is_none(), + "{fault}" + ); + assert!( + error + .details + .as_deref() + .unwrap() + .get("policyResultUnavailable") + .is_some(), + "{fault}" + ); + assert_eq!(error.native_code.as_deref(), Some("0x800704EC")); + assert_eq!(error.message, POLICY_DENIED_MESSAGE, "{fault}"); + STATE.with(|state| assert_eq!(state.borrow().frees, 1, "{fault}")); + } +} + +#[test] +fn policy_failures_native_maximum_byte_count_remains_usable() { + let api = reset(); + STATE.with(|state| { + let mut state = state.borrow_mut(); + let native = PolicyResult::from(&state.report); + let mut builder = flatbuffers::FlatBufferBuilder::new(); + let root = native.pack(&mut builder); + layout::finish_policy_result_buffer(&mut builder, root); + let mut bytes = builder.finished_data().to_vec(); + bytes.resize(MAX_RESULT_BYTES, 0); + state.payload_override = Some(bytes); + }); + let failure = api.create_with_diagnostics(b"spec", 0).unwrap_err(); + let error = crate::mxc_engine::Error::from(failure.response(false).into_mxc_error()); + assert_eq!( + PolicyFailures::from_error(&error) + .unwrap() + .unwrap() + .results + .len(), + 1 + ); + STATE.with(|state| assert_eq!(state.borrow().frees, 1)); +} + +#[test] +fn policy_failures_from_malformed_error_details_use_one_canonical_error() { + let api = reset(); + let failure = api.create_with_diagnostics(b"spec", 0).unwrap_err(); + let valid = crate::mxc_engine::Error::from(failure.response(false).into_mxc_error()); + let stored = valid.details.as_deref().unwrap()["policyFailures"].clone(); + let mut wrong_version = stored.clone(); + wrong_version["formatVersion"] = 2.into(); + let mut over_limit = stored.clone(); + let result = stored["results"][0].clone(); + over_limit["results"] = + vec![result; crate::mxc_common::policy_failures::MAX_RESULTS + 2].into(); + for (case, policy_failures) in [ + ("formatVersion", wrong_version), + ("resultLimit", over_limit), + ("shape", serde_json::json!([])), + ] { + let mut error = valid.clone(); + error.details = Some(Box::new( + serde_json::json!({ "policyFailures": policy_failures }), + )); + let malformed = PolicyFailures::from_error(&error).unwrap_err(); + assert_eq!( + malformed.code, + crate::mxc_engine::ErrorCode::BackendError, + "{case}" + ); + assert_eq!( + malformed.message, "native policy failures are malformed", + "{case}" + ); + } +} + +#[test] +fn policy_failures_caller_merge_keeps_a_complete_terminal_batch() { + let api = reset(); + let mut prior = PolicyFailures::default(); + for index in 0..64 { + let mut result = STATE.with(|state| state.borrow().report.clone()); + result.details[0].resource = Some(format!(r"C:\history\{index}").encode_utf16().collect()); + prior.append(result); + } + prior.validate().unwrap(); + let failure = api.create_with_diagnostics(b"spec", 0).unwrap_err(); + let error = crate::mxc_engine::Error::from(failure.response(false).into_mxc_error()); + assert!(error + .details + .as_deref() + .unwrap() + .get("policyFeedbackError") + .is_none()); + assert_eq!(error.native_code.as_deref(), Some("0x800704EC")); + assert_eq!( + error.operation.as_deref(), + Some("CreateProcessSecurityEnvironment") + ); + let current = PolicyFailures::from_error(&error).unwrap().unwrap(); + assert_eq!(current.results.len(), 1); + let complete = prior.merged_with(¤t).unwrap(); + assert_eq!(complete.results.len(), 65); + assert_eq!(&complete.results[..64], &prior.results); + assert_eq!( + PolicyFailures::from_json(&complete.to_json().unwrap()).unwrap(), + complete + ); + assert!(complete.validate().is_err()); + STATE.with(|state| assert_eq!(state.borrow().frees, 1)); +} + +#[test] +fn policy_preparation_capture_removal_is_authorable_and_strictly_submitted() { + use crate::core_modules::process_security_environment_spec::process_security_environment_layout as psec; + use crate::mxc_common::logger::{Logger, Mode}; + use crate::mxc_common::sandbox_process::{SandboxBackend, StdioMode}; + use crate::process_container_common::base_container_runner::BaseContainerRunner; + use crate::process_container_common::test_env::CaptureCapabilityGuard; + use std::{io::Read, sync::Arc}; + + let _host = CaptureCapabilityGuard::set(true, true); + let mut api = reset(); + api.version_support = Some(version); + let _scope = ApiScope(INJECTED_API.with(|slot| slot.replace(Some(api)))); + STATE.with(|state| { + let mut state = state.borrow_mut(); + state.status = S_OK; + state.handle = true; + }); + let mut failures = PolicyFailures::default(); + for capability in ["learningModeLogging", "permissiveLearningMode"] { + failures.append(PolicyFailureResult { + outcome: 1, + details: vec![PolicyFailureDetail { + failure_class: 3, + failure_reason: 1, + required_action: 1, + resource_kind: 2, + flags: 3, + resource: Some(capability.encode_utf16().collect()), + ..Default::default() + }], + ..Default::default() + }); + } + let directory = tempfile::tempdir().unwrap(); + let original = serde_json::json!({ + "version": "1.1.0-alpha", + "containment": "process", + "process": { + "commandLine": "cmd.exe /d /c echo mxc-policy-child", + "cwd": directory.path(), "timeout": 5000, + }, + "processContainer": { + "learningMode": true, + "captureDenials": { + "mode": "allow", "retainEtl": true, + "outputPath": directory.path().join("capture.json"), + } + } + }) + .to_string(); + let prepared = prepare_candidate(&original, &failures); + let candidate: serde_json::Value = serde_json::from_str(&prepared.request_json).unwrap(); + assert_eq!(candidate["version"], "1.1.0-alpha"); + assert_eq!(candidate["processContainer"]["learningMode"], false); + assert!(candidate["processContainer"] + .get("captureDenials") + .is_none()); + assert!(candidate["processContainer"].get("capabilities").is_none()); + assert!(prepared.changes.iter().any(|change| { + change.path == "/processContainer/captureDenials" + && change.operation == crate::v1::dev::PolicyChangeOperation::Remove + && change.before.is_object() + && change.after.is_null() + })); + let request = parsed_request(&prepared.request_json); + let mut runner = BaseContainerRunner::with_capture_factory(Arc::new(UnusedCapture)) + .with_process_launcher(launch_fixed_fixture); + let mut process = runner + .spawn(&request, &mut Logger::new(Mode::Buffer), StdioMode::Pipes) + .unwrap(); + let mut stdout = String::new(); + process + .take_stdout() + .unwrap() + .read_to_string(&mut stdout) + .unwrap(); + assert_eq!(stdout.trim(), "mxc-policy-child"); + assert_eq!(process.wait().unwrap(), 0); + drop(process); + assert_eq!( + parsed_request(&original) + .policy + .capture_denials + .unwrap() + .mode, + crate::mxc_common::models::CaptureDenialsMode::Allow + ); + STATE.with(|state| { + let state = state.borrow(); + assert_eq!(state.specifications.len(), 1); + let specification = + psec::root_as_process_security_environment(&state.specifications[0]).unwrap(); + assert_eq!(specification.capabilities(), None); + assert_eq!( + state.calls.iter().filter(|call| **call == "launch").count(), + 1 + ); + assert_eq!(state.closes, 1); + }); +} + +#[test] +fn policy_failures_native_capture_preserves_data_status_and_cleanup_not_prose() { + let api = reset(); + STATE.with(|state| state.borrow_mut().handle = true); + let error = api.create_with_diagnostics(b"spec", 0x40).unwrap_err(); + let rendered = format!("{error:?}"); + assert!(!rendered.contains("67, 58, 92, 119"), "{rendered}"); + assert!(rendered.contains(""), "{rendered}"); + let error = crate::mxc_engine::Error::from(error.response(false).into_mxc_error()); + let failures = PolicyFailures::from_error(&error).unwrap().unwrap(); + assert_eq!( + error.operation.as_deref(), + Some("CreateProcessSecurityEnvironment") + ); + assert_eq!(error.native_code.as_deref(), Some("0x800704EC")); + assert_eq!(error.message, POLICY_DENIED_MESSAGE); + assert_eq!( + failures.results[0].details[0] + .resource_text() + .unwrap() + .as_deref(), + Some(r"C:\work") + ); + STATE.with(|state| { + let state = state.borrow(); + assert_eq!( + state.calls.iter().filter(|call| **call == "create").count(), + 1 + ); + assert!( + state.calls.iter().position(|call| *call == "get").unwrap() + < state + .calls + .iter() + .position(|call| *call == "close") + .unwrap() + ); + assert_eq!(state.flags, [0x42]); + assert_eq!(state.frees, 1); + assert_eq!(state.closes, 1); + assert!(state + .threads + .iter() + .all(|id| *id == std::thread::current().id())); + }); +} + +#[test] +fn policy_failures_are_not_queried_after_success_or_unrelated_failure() { + for status in [S_OK, E_FAIL] { + let api = reset(); + STATE.with(|state| { + let mut state = state.borrow_mut(); + state.status = status; + state.handle = status.is_ok(); + }); + drop(api.create_with_diagnostics(b"spec", 0)); + STATE.with(|state| { + let state = state.borrow(); + assert!(!state.calls.contains(&"get")); + assert_eq!(state.frees, 0); + assert_eq!(state.closes, usize::from(status.is_ok())); + }); + } +} + +#[test] +fn policy_failures_getter_failure_frees_outputs_and_reports_unavailability() { + let api = reset(); + STATE.with(|state| state.borrow_mut().getter_status = E_FAIL); + let failure = api.create_with_diagnostics(b"spec", 0).unwrap_err(); + let error = crate::mxc_engine::Error::from(failure.response(false).into_mxc_error()); + assert!(PolicyFailures::from_error(&error).unwrap().is_none()); + assert!(error + .details + .as_deref() + .unwrap() + .get("policyResultUnavailable") + .is_some()); + assert_eq!(error.native_code.as_deref(), Some("0x800704EC")); + STATE.with(|state| assert_eq!(state.borrow().frees, 1)); +} + +#[test] +fn policy_failures_missing_getter_reports_unavailability_only_as_structured_data() { + let mut api = reset(); + api.get_last_policy_result = None; + let failure = api.create_with_diagnostics(b"spec", 0).unwrap_err(); + let error = crate::mxc_engine::Error::from(failure.response(false).into_mxc_error()); + assert!(PolicyFailures::from_error(&error).unwrap().is_none()); + assert!(error + .details + .as_deref() + .unwrap() + .get("policyResultUnavailable") + .is_some()); + assert_eq!(error.message, POLICY_DENIED_MESSAGE); + STATE.with(|state| { + let state = state.borrow(); + assert_eq!(state.flags, [0]); + assert!(!state.calls.contains(&"get")); + }); +} + +#[test] +fn policy_failures_unadvertised_or_failed_support_preserves_strict_creation() { + for (support_status, support_flags) in [(S_OK, 0), (S_OK, 0x0f), (E_FAIL, 0x10)] { + let api = reset(); + STATE.with(|state| { + let mut state = state.borrow_mut(); + state.support_status = support_status; + state.support_flags = support_flags; + }); + let caller_flags = 0x40; + let failure = api + .create_with_diagnostics(b"spec", caller_flags) + .unwrap_err(); + STATE.with(|state| { + let state = state.borrow(); + assert_eq!(state.flags, [caller_flags]); + assert_eq!(state.calls, ["support", "create"]); + assert_eq!(state.frees, 0); + }); + let error = crate::mxc_engine::Error::from(failure.response(false).into_mxc_error()); + assert_eq!(error.native_code.as_deref(), Some("0x800704EC")); + assert_eq!( + error.operation.as_deref(), + Some("CreateProcessSecurityEnvironment") + ); + assert!(PolicyFailures::from_error(&error).unwrap().is_none()); + assert!(error.details.as_deref().unwrap()["policyResultUnavailable"] + .as_str() + .is_some_and(|reason| !reason.is_empty())); + assert_eq!(error.message, POLICY_DENIED_MESSAGE); + } +} diff --git a/src/mxc-sdk/src/backends/process_container/common/working_directory.rs b/src/mxc-sdk/src/backends/process_container/common/working_directory.rs index 2c2478b751..bdb181498e 100644 --- a/src/mxc-sdk/src/backends/process_container/common/working_directory.rs +++ b/src/mxc-sdk/src/backends/process_container/common/working_directory.rs @@ -12,7 +12,9 @@ //! so the runner never passes `NULL`. The mapping has unit coverage that //! needs no prepared host. -use crate::mxc_common::models::{ExecutionRequest, WorkingDirectorySource}; +use crate::mxc_common::models::{ + ExecutionRequest, ResolvedWorkingDirectory, WorkingDirectorySource, +}; /// Drive root used when neither `process.cwd` nor the filesystem policy yields /// a usable directory. @@ -78,7 +80,27 @@ pub fn launch_working_directory_with( is_dir: impl Fn(&str) -> bool, drive_root: &str, ) -> LaunchWorkingDirectory { - match request.resolved_working_directory_with(is_dir) { + launch_directory_from_resolved(request.resolved_working_directory_with(is_dir), drive_root) +} + +pub(crate) fn try_launch_working_directory( + request: &ExecutionRequest, +) -> Result { + let mut probe = crate::mxc_common::filesystem_probe::FilesystemProbe::default(); + let resolved = request.try_resolved_working_directory_with(|path| { + probe.run(path, |path| std::path::Path::new(path).is_dir()) + })?; + Ok(launch_directory_from_resolved( + resolved, + &system_drive_root(), + )) +} + +fn launch_directory_from_resolved( + resolved: Option>, + drive_root: &str, +) -> LaunchWorkingDirectory { + match resolved { Some(resolved) => LaunchWorkingDirectory { path: resolved.path.to_string(), source: match resolved.source { diff --git a/src/mxc-sdk/src/core/mod.rs b/src/mxc-sdk/src/core/mod.rs index 17a8424e7b..6846055742 100644 --- a/src/mxc-sdk/src/core/mod.rs +++ b/src/mxc-sdk/src/core/mod.rs @@ -9,4 +9,6 @@ pub mod mxc_engine; pub mod mxc_pty; pub mod mxc_schema_support; pub mod mxc_telemetry; +#[allow(dead_code)] +pub(crate) mod process_security_environment_policy_result; pub mod process_security_environment_spec; diff --git a/src/mxc-sdk/src/core/mxc_common/mod.rs b/src/mxc-sdk/src/core/mxc_common/mod.rs index 8eceab98e4..b6ca447fc7 100644 --- a/src/mxc-sdk/src/core/mxc_common/mod.rs +++ b/src/mxc-sdk/src/core/mxc_common/mod.rs @@ -28,7 +28,9 @@ pub(crate) mod module_lifetime; pub mod mxc_error; pub mod network_blocks; pub(crate) mod network_parser; +pub(crate) mod policy_failures; pub mod policy_identity; +pub(crate) mod policy_preparation; pub use network_parser::host_is_canonical_loopback; pub mod proxy_env; pub mod sandbox_process; diff --git a/src/mxc-sdk/src/core/mxc_common/models.rs b/src/mxc-sdk/src/core/mxc_common/models.rs index 5cfaa39453..9e564ca0bd 100644 --- a/src/mxc-sdk/src/core/mxc_common/models.rs +++ b/src/mxc-sdk/src/core/mxc_common/models.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use std::net::IpAddr; use crate::mxc_common::error::WxcError; -use crate::mxc_common::mxc_error::MxcErrorCode; +use crate::mxc_common::mxc_error::{ErrorEnvelope, MxcError, MxcErrorCode}; /// Selects which containment backend to use for script execution. #[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -1127,23 +1127,40 @@ impl ExecutionRequest { &self, is_dir: impl Fn(&str) -> bool, ) -> Option> { + match self.try_resolved_working_directory_with(|path| { + Ok::<_, std::convert::Infallible>(is_dir(path)) + }) { + Ok(directory) => directory, + Err(never) => match never {}, + } + } + + pub(crate) fn try_resolved_working_directory_with( + &self, + mut is_dir: impl FnMut(&str) -> Result, + ) -> Result>, E> { if !self.working_directory.trim().is_empty() { - return Some(ResolvedWorkingDirectory { + return Ok(Some(ResolvedWorkingDirectory { path: self.working_directory.as_str(), source: WorkingDirectorySource::Explicit, - }); + })); } - self.policy + for path in self + .policy .readwrite_paths .iter() .chain(self.policy.readonly_paths.iter()) .map(String::as_str) .filter(|path| !path.trim().is_empty()) - .find(|path| is_dir(path)) - .map(|path| ResolvedWorkingDirectory { - path, - source: WorkingDirectorySource::Policy, - }) + { + if is_dir(path)? { + return Ok(Some(ResolvedWorkingDirectory { + path, + source: WorkingDirectorySource::Policy, + })); + } + } + Ok(None) } } @@ -1230,8 +1247,13 @@ pub struct ScriptResponse { /// [`warnings`](ScriptResponse::warnings) and /// [`extend_warnings`](ScriptResponse::extend_warnings) rather than the /// field, so the "absent" and "empty" spellings cannot drift apart. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub warnings: Option>>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_diagnostics" + )] + #[serde(flatten)] + pub diagnostics: Option>, /// Structured metadata produced after the sandboxed process exits. #[serde(default, skip_serializing_if = "Option::is_none")] pub output_metadata: Option>, @@ -1246,12 +1268,35 @@ impl Default for ScriptResponse { error_message: String::new(), extended_error: String::new(), failure_phase: FailurePhase::None, - warnings: None, + diagnostics: None, output_metadata: None, } } } +/// Boxed response diagnostics keep failures small while preserving warning +/// serialization and the complete structured error. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct ScriptResponseDiagnostics { + #[serde(skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +// A flattened optional struct deserializes to `Some(default)` when its keys +// are absent; keep "no diagnostics" as `None`. +fn deserialize_diagnostics<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + let diagnostics = Option::>::deserialize(deserializer)?; + Ok(diagnostics.filter(|d| !d.warnings.is_empty() || d.error.is_some())) +} + /// Structured outputs produced by optional sandbox features. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -1349,7 +1394,9 @@ impl ScriptResponse { /// The non-fatal diagnostics this run recorded. pub fn warnings(&self) -> &[String] { - self.warnings.as_deref().map_or(&[], Vec::as_slice) + self.diagnostics + .as_deref() + .map_or(&[], |diagnostics| diagnostics.warnings.as_slice()) } /// Append diagnostics, allocating only once there is something to record. @@ -1358,10 +1405,41 @@ impl ScriptResponse { if incoming.peek().is_none() { return; } - self.warnings + self.diagnostics .get_or_insert_with(Box::default) + .warnings .extend(incoming); } + + #[cfg(any(target_os = "windows", test))] + pub(crate) fn with_error(mut self, error: MxcError) -> Self { + self.diagnostics.get_or_insert_with(Box::default).error = Some(error.to_envelope()); + self + } + + pub(crate) fn structured_error(&self) -> Option<&ErrorEnvelope> { + self.diagnostics + .as_ref() + .and_then(|diagnostics| diagnostics.error.as_ref()) + } + + pub(crate) fn into_mxc_error(mut self) -> MxcError { + if let Some(error) = self + .diagnostics + .as_mut() + .and_then(|diagnostics| diagnostics.error.take()) + { + return MxcError::from_envelope(error); + } + let message = if self.extended_error.is_empty() { + self.error_message + } else if self.error_message.is_empty() { + self.extended_error + } else { + format!("{} ({})", self.error_message, self.extended_error) + }; + MxcError::new(self.failure_phase.error_code(), message) + } } #[cfg(test)] @@ -1435,16 +1513,40 @@ mod tests { fn recording_nothing_allocates_nothing() { let mut response = ScriptResponse::default(); response.extend_warnings(Vec::new()); - assert!(response.warnings.is_none()); + assert!(response.diagnostics.is_none()); assert!(response.warnings().is_empty()); } + #[test] + fn response_wire_shape_keeps_the_warnings_array_and_optional_top_level_error() { + let bare: ScriptResponse = serde_json::from_str(r#"{"exit_code":0}"#).unwrap(); + assert!(bare.diagnostics.is_none()); + assert!(bare.warnings().is_empty()); + + let mut response = + ScriptResponse::rejected("nope").with_error(MxcError::policy_validation("nope")); + response.extend_warnings(["careful".to_string()]); + let wire = serde_json::to_value(&response).unwrap(); + assert_eq!(wire["warnings"], serde_json::json!(["careful"])); + assert_eq!(wire["error"]["message"], "nope"); + let restored: ScriptResponse = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(restored.warnings(), ["careful"]); + assert_eq!(restored.structured_error().unwrap().message, "nope"); + assert_eq!(serde_json::to_value(&restored).unwrap(), wire); + + let only_error = serde_json::to_value( + ScriptResponse::rejected("nope").with_error(MxcError::policy_validation("nope")), + ) + .unwrap(); + assert!(only_error.get("warnings").is_none()); + } + // `ScriptResponse` is used as a `Result` error type, so it has to stay // under clippy's `result_large_err` threshold. #[test] fn the_response_stays_small_enough_to_be_an_error_type() { assert!( - std::mem::size_of::() <= 128, + std::mem::size_of::() < 128, "{} bytes", std::mem::size_of::() ); @@ -1532,6 +1634,28 @@ mod tests { assert_eq!(resolved.source, WorkingDirectorySource::Explicit); } + #[test] + fn fallible_working_directory_probe_stops_on_unknown_paths_and_preserves_explicit_cwd() { + let mut req = request_with_paths(&["C:\\blocked", "C:\\fallback"], &[]); + let mut calls = Vec::new(); + let error = req + .try_resolved_working_directory_with(|path| { + calls.push(path.to_owned()); + Err::("probe deadline") + }) + .unwrap_err(); + assert_eq!(error, "probe deadline"); + assert_eq!(calls, ["C:\\blocked"]); + req.working_directory = "C:\\explicit".into(); + let resolved = req + .try_resolved_working_directory_with(|_| -> Result { + panic!("an explicit cwd must not use a policy directory probe") + }) + .unwrap() + .unwrap(); + assert_eq!(resolved.path, "C:\\explicit"); + } + /// An explicit cwd is the caller's own choice, so it is returned unchecked /// rather than silently swapped for a policy path when it does not exist. #[test] diff --git a/src/mxc-sdk/src/core/mxc_common/mxc_error.rs b/src/mxc-sdk/src/core/mxc_common/mxc_error.rs index 928ee11c28..cd533d7835 100644 --- a/src/mxc-sdk/src/core/mxc_common/mxc_error.rs +++ b/src/mxc-sdk/src/core/mxc_common/mxc_error.rs @@ -140,7 +140,7 @@ impl ApiFailure { /// describes the platform call and on `MxcError` when it describes the /// failure; *backend-specific* structured data belongs in `details`, which /// stays open for that purpose. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq)] #[non_exhaustive] pub struct MxcError { pub code: MxcErrorCode, @@ -176,6 +176,20 @@ impl fmt::Display for MxcError { impl std::error::Error for MxcError {} +/// Debug output omits `details`, which can hold policy resource names. +impl fmt::Debug for MxcError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MxcError") + .field("code", &self.code) + .field("message", &self.message) + .field("details", &self.details.as_ref().map(|_| "")) + .field("api_failure", &self.api_failure) + .field("remediation", &self.remediation) + .finish() + } +} + impl MxcError { pub fn new(code: MxcErrorCode, message: impl Into) -> Self { Self { @@ -287,7 +301,7 @@ impl MxcError { /// /// See [`MxcError`] for the meaning of the structured failure fields and the /// invariant relating them. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] #[non_exhaustive] pub struct ErrorEnvelope { pub code: MxcErrorCode, @@ -306,6 +320,35 @@ pub struct ErrorEnvelope { pub remediation: Option, } +/// Debug output omits `details`, which can hold policy resource names. +impl fmt::Debug for ErrorEnvelope { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ErrorEnvelope") + .field("code", &self.code) + .field("message", &self.message) + .field("details", &self.details.as_ref().map(|_| "")) + .field("operation", &self.operation) + .field("native_code", &self.native_code) + .field("remediation", &self.remediation) + .finish() + } +} + +impl MxcError { + pub(crate) fn from_envelope(error: ErrorEnvelope) -> Self { + let mut result = Self::new(error.code, error.message); + result.details = error.details; + result.remediation = error.remediation; + if let Some(operation) = error.operation { + let mut api = ApiFailure::new(operation); + api.native_code = error.native_code; + result = result.with_api_failure(api); + } + result + } +} + /// Top-level non-exec response envelope: `{"result": }` on success, or /// `{"error": {...}}` on failure. `T` is per-phase (e.g. provision metadata, /// or `()` for phases without a return body). @@ -618,6 +661,31 @@ mod tests { ); } + #[test] + fn debug_shows_structured_fields_but_not_policy_details() { + let err = MxcError::backend_error("Creation refused.") + .with_details(serde_json::json!({ + "policyFailures": { "resource": "C:\\POLICY_RESOURCE_MARKER" } + })) + .with_api_failure(full_api_failure()) + .with_remediation("Prepare a restricted request."); + let rendered = format!("{err:?}"); + assert!(!rendered.contains("POLICY_RESOURCE_MARKER"), "{rendered}"); + let envelope = format!("{:?}", err.to_envelope()); + assert!(!envelope.contains("POLICY_RESOURCE_MARKER"), "{envelope}"); + assert!(envelope.contains(""), "{envelope}"); + for visible in [ + "BackendError", + "Creation refused.", + "IsoSessionOps.AddUserAsync", + "0x80070490", + "Prepare a restricted request.", + "", + ] { + assert!(rendered.contains(visible), "{visible} missing: {rendered}"); + } + } + /// `Display` is a rendering concern only — the wire `message` stays the /// bare API text, with the components in their own fields. #[test] diff --git a/src/mxc-sdk/src/core/mxc_common/policy_failures.rs b/src/mxc-sdk/src/core/mxc_common/policy_failures.rs new file mode 100644 index 0000000000..41eb1b338e --- /dev/null +++ b/src/mxc-sdk/src/core/mxc_common/policy_failures.rs @@ -0,0 +1,461 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Owned creation-policy observations shared by the SDK adapters. + +use serde::{Deserialize, Serialize}; + +pub(crate) const FORMAT_VERSION: u32 = 1; +pub(crate) const MAX_POLICY_FAILURES_BYTES: usize = 4 * 1024 * 1024; +const MAX_STORED_POLICY_FAILURES_BYTES: usize = MAX_POLICY_FAILURES_BYTES + 512 * 1024; +pub(crate) const MAX_RESULTS: usize = 64; +pub(crate) const MAX_DETAILS: usize = 1024; +pub(crate) const MAX_RESOURCE_UNITS: usize = 262_144; + +/// Windows creation-policy failures collected by the application, not a policy approval. +/// +/// Pass this value to V1.Dev policy preparation to propose supported +/// restrictive changes. The application owns acceptance, storage and retries. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[non_exhaustive] +pub struct PolicyFailures { + /// Version of this MXC policy-failures format, independently of native versions. + pub format_version: u32, + /// Native refusal batches. Creation returns only the batches from that + /// attempt; the application merges earlier ones. + pub results: Vec, +} + +impl Default for PolicyFailures { + fn default() -> Self { + Self { + format_version: FORMAT_VERSION, + results: Vec::new(), + } + } +} + +/// One native policy-result batch. Its details are non-exhaustive. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[non_exhaustive] +pub struct PolicyFailureResult { + /// Native PSEP contract version. + pub version: u32, + /// Minimum native reader version. + pub minimum_reader_version: u32, + /// Native outcome; compare with the `OUTCOME_*` associated constants. + /// Unrecognized numeric values are preserved. + pub outcome: u32, + /// Details belonging to this native batch. + pub details: Vec, +} + +impl PolicyFailureResult { + /// Unspecified outcome, not a policy approval. + pub const OUTCOME_UNKNOWN: u32 = 0; + /// Creation was blocked by policy. + pub const OUTCOME_BLOCKED: u32 = 1; + /// Policy evaluation failed. + pub const OUTCOME_EVALUATION_FAILED: u32 = 2; +} + +impl Default for PolicyFailureResult { + fn default() -> Self { + Self { + version: 1, + minimum_reader_version: 1, + outcome: Self::OUTCOME_UNKNOWN, + details: Vec::new(), + } + } +} + +/// A native policy failure. Numeric discriminators and flags retain unknown values. +/// +/// `requested_value` is an observation about the request Windows evaluated, +/// not necessarily the request supplied to a subsequent call. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[non_exhaustive] +pub struct PolicyFailureDetail { + /// Native domain; compare with the `CLASS_*` associated constants. + pub failure_class: u32, + /// Native cause; compare with the `REASON_*` associated constants. + pub failure_reason: u32, + /// Suggested change; compare with the `ACTION_*` associated constants. + pub required_action: u32, + /// Resource category; compare with the `RESOURCE_KIND_*` associated constants. + pub resource_kind: u32, + /// Value category; compare with the `VALUE_KIND_*` associated constants. + pub value_kind: u32, + /// Native bitmask; inspect the `FLAG_*` associated constants. + pub flags: u32, + /// Decimal string in JSON, so all `u64` values survive JavaScript storage. + #[serde(with = "decimal_u64")] + pub requested_value: u64, + /// Decimal string in JSON, so all `u64` values survive JavaScript storage. + #[serde(with = "decimal_u64")] + pub required_value: u64, + /// Native UTF-16 resource units without a terminator. Absence, empty data, + /// and malformed UTF-16 remain distinguishable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resource: Option>, +} + +impl PolicyFailureDetail { + /// No policy domain specified. + pub const CLASS_NONE: u32 = 0; + /// Network policy. + pub const CLASS_NETWORK: u32 = 1; + /// Filesystem policy. + pub const CLASS_FILESYSTEM: u32 = 2; + /// Enforcement-mode policy. + pub const CLASS_ENFORCEMENT_MODE: u32 = 3; + + /// No failure reason specified. + pub const REASON_NONE: u32 = 0; + /// A requested capability is not allowed. + pub const REASON_CAPABILITY_NOT_ALLOWED: u32 = 1; + /// Requested access exceeds the policy ceiling. + pub const REASON_ACCESS_EXCEEDS_CEILING: u32 = 2; + /// Policy requires a deny entry. + pub const REASON_DENY_REQUIRED: u32 = 3; + /// The request exceeds the policy path limit. + pub const REASON_REQUEST_PATH_LIMIT: u32 = 4; + + /// No request change suggested. + pub const ACTION_NONE: u32 = 0; + /// Remove a requested capability. + pub const ACTION_REMOVE_CAPABILITY: u32 = 1; + /// Restrict requested filesystem access. + pub const ACTION_RESTRICT_FILESYSTEM_ACCESS: u32 = 2; + /// Reduce the requested path count. + pub const ACTION_REDUCE_PATH_COUNT: u32 = 3; + + /// No resource category specified. + pub const RESOURCE_KIND_NONE: u32 = 0; + /// A filesystem path. + pub const RESOURCE_KIND_PATH: u32 = 1; + /// A capability name. + pub const RESOURCE_KIND_CAPABILITY: u32 = 2; + + /// No value category specified. + pub const VALUE_KIND_NONE: u32 = 0; + /// Values describe access; compare with the `ACCESS_*` associated constants. + pub const VALUE_KIND_ACCESS: u32 = 1; + /// Values describe path counts. + pub const VALUE_KIND_PATH_COUNT: u32 = 2; + + /// No access, when `value_kind` is [`Self::VALUE_KIND_ACCESS`]. + pub const ACCESS_NONE: u64 = 0; + /// Read-only access, when `value_kind` is [`Self::VALUE_KIND_ACCESS`]. + pub const ACCESS_READ_ONLY: u64 = 1; + /// Read-write access, when `value_kind` is [`Self::VALUE_KIND_ACCESS`]. + pub const ACCESS_READ_WRITE: u64 = 2; + + /// No flags set. + pub const FLAG_NONE: u32 = 0; + /// A typed remedy is identified, not necessarily safe or available. + pub const FLAG_HAS_ACTIONABLE_DETAILS: u32 = 1; + /// The resource text is complete. + pub const FLAG_RESOURCE_COMPLETE: u32 = 2; + /// The resource text is unavailable. + pub const FLAG_RESOURCE_UNAVAILABLE: u32 = 4; + + /// Decode resource text without replacing malformed UTF-16. + /// + /// This does not establish that the resource is complete or actionable; + /// those conditions are validated again when the observations are applied. + pub fn resource_text(&self) -> Result, std::string::FromUtf16Error> { + self.resource.as_deref().map(String::from_utf16).transpose() + } +} + +impl PolicyFailures { + /// Policy-refusal HRESULT as represented by an SDK error's `native_code`. + /// + /// This is `HRESULT_FROM_WIN32(ERROR_ACCESS_DISABLED_BY_POLICY)`. + pub const NATIVE_CODE_POLICY_BLOCKED: &str = "0x800704EC"; + + pub(crate) fn validate(&self) -> Result<(), String> { + self.validate_limits(false) + } + + pub(crate) fn validate_storage(&self) -> Result<(), String> { + self.validate_limits(true) + } + + fn validate_limits(&self, terminal_result: bool) -> Result<(), String> { + if self.format_version != FORMAT_VERSION { + return Err("unsupported policy failures formatVersion".into()); + } + if self.results.len() > MAX_RESULTS + usize::from(terminal_result) { + return Err("policy failures exceed the result limit".into()); + } + let mut details = 0usize; + let mut resources = 0usize; + for result in &self.results { + if result.details.len() > 64 { + return Err("policy failures exceed the per-result detail limit".into()); + } + details = details.saturating_add(result.details.len()); + let mut result_resources = 0usize; + for detail in &result.details { + if let Some(resource) = &detail.resource { + result_resources = result_resources.saturating_add(resource.len() + 1); + } + } + if result_resources > 32_768 { + return Err("policy failures exceed the per-result resource limit".into()); + } + resources = resources.saturating_add(result_resources); + } + if details > MAX_DETAILS + if terminal_result { 64 } else { 0 } + || resources > MAX_RESOURCE_UNITS + if terminal_result { 32_768 } else { 0 } + { + return Err("policy failures exceed the total detail/resource limit".into()); + } + let mut counter = SizeCounter { + bytes: 0, + limit: if terminal_result { + MAX_STORED_POLICY_FAILURES_BYTES + } else { + MAX_POLICY_FAILURES_BYTES + }, + }; + serde_json::to_writer(&mut counter, self) + .map_err(|error| format!("invalid policy failures: {error}")) + } + + pub(crate) fn parse(json: &str) -> Result { + if json.len() > MAX_POLICY_FAILURES_BYTES { + return Err("policy failures exceed the byte limit".into()); + } + let failures: Self = + serde_json::from_str(json).map_err(|_| "invalid policy failures JSON".to_owned())?; + failures.validate()?; + Ok(failures) + } + + pub(crate) fn parse_stored(json: &str) -> Result { + if json.len() > MAX_STORED_POLICY_FAILURES_BYTES { + return Err("stored policy failures exceed the byte limit".into()); + } + let failures: Self = + serde_json::from_str(json).map_err(|_| "invalid policy failures JSON".to_owned())?; + failures.validate_storage()?; + Ok(failures) + } + + /// Append without dropping native information. A caller of this internal + /// helper must report a resulting limit violation instead of truncating it. + #[cfg(any(target_os = "windows", test))] + pub(crate) fn append(&mut self, result: PolicyFailureResult) { + if !self.results.contains(&result) { + self.results.push(result); + } + } +} + +struct SizeCounter { + bytes: usize, + limit: usize, +} + +impl std::io::Write for SizeCounter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.bytes = self.bytes.saturating_add(bytes.len()); + if self.bytes > self.limit { + return Err(std::io::Error::other( + "policy failures exceed the byte limit", + )); + } + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +mod decimal_u64 { + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(value: &u64, serializer: S) -> Result { + serializer.serialize_str(&value.to_string()) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(serde::de::Error::custom( + "expected an unsigned decimal string", + )); + } + value.parse().map_err(serde::de::Error::custom) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn policy_failures_round_trip_preserves_large_values_unknown_codes_and_utf16() { + let detail = PolicyFailureDetail { + failure_class: u32::MAX, + requested_value: u64::MAX, + required_value: 9_007_199_254_740_993, + resource: Some(vec![0xd800, 0]), + ..Default::default() + }; + assert!(detail.resource_text().is_err()); + let mut original = PolicyFailures::default(); + original.append(PolicyFailureResult { + outcome: u32::MAX, + details: vec![detail], + ..Default::default() + }); + original.append(PolicyFailureResult { + outcome: PolicyFailureResult::OUTCOME_BLOCKED, + details: vec![PolicyFailureDetail { + failure_class: PolicyFailureDetail::CLASS_FILESYSTEM, + failure_reason: PolicyFailureDetail::REASON_ACCESS_EXCEEDS_CEILING, + required_action: PolicyFailureDetail::ACTION_RESTRICT_FILESYSTEM_ACCESS, + resource_kind: PolicyFailureDetail::RESOURCE_KIND_PATH, + value_kind: PolicyFailureDetail::VALUE_KIND_ACCESS, + flags: PolicyFailureDetail::FLAG_HAS_ACTIONABLE_DETAILS + | PolicyFailureDetail::FLAG_RESOURCE_COMPLETE, + requested_value: PolicyFailureDetail::ACCESS_READ_WRITE, + required_value: PolicyFailureDetail::ACCESS_READ_ONLY, + ..Default::default() + }], + ..Default::default() + }); + let json = serde_json::to_string(&original).unwrap(); + assert!(json.contains("\"18446744073709551615\"")); + assert_eq!( + serde_json::from_str::(&json).unwrap()["results"][1], + serde_json::json!({ + "version": 1, "minimumReaderVersion": 1, "outcome": 1, + "details": [{ + "failureClass": 2, "failureReason": 2, "requiredAction": 2, + "resourceKind": 1, "valueKind": 1, "flags": 3, + "requestedValue": "2", "requiredValue": "1" + }] + }) + ); + assert_eq!(PolicyFailures::parse(&json).unwrap(), original); + + let mut nullable = serde_json::to_value(&original).unwrap(); + nullable["results"][0]["details"][0]["resource"] = serde_json::Value::Null; + let restored = PolicyFailures::parse(&nullable.to_string()).unwrap(); + let mut absent = original; + absent.results[0].details[0].resource = None; + assert_eq!(restored, absent); + assert!( + serde_json::to_value(restored).unwrap()["results"][0]["details"][0] + .get("resource") + .is_none() + ); + } + + #[test] + fn policy_failures_reject_bad_versions_extra_fields_and_lossy_numbers() { + for json in [ + r#"{"formatVersion":2,"results":[]}"#, + r#"{"formatVersion":1,"results":[],"extra":true}"#, + r#"{"formatVersion":1,"results":[{"version":1,"minimumReaderVersion":1,"outcome":1,"details":[{"failureClass":2,"failureReason":2,"requiredAction":2,"resourceKind":1,"valueKind":1,"flags":3,"requestedValue":2,"requiredValue":"1"}]}]}"#, + ] { + assert!(PolicyFailures::parse(json).is_err(), "{json}"); + } + } + + #[test] + fn policy_failures_total_limits_are_not_silent_truncation() { + let mut failures = PolicyFailures::default(); + for outcome in 0..=MAX_RESULTS as u32 { + failures.append(PolicyFailureResult { + outcome, + ..Default::default() + }); + } + assert_eq!(failures.results.len(), MAX_RESULTS + 1); + assert_eq!( + failures.validate().unwrap_err(), + "policy failures exceed the result limit" + ); + } + + #[test] + fn policy_failures_merge_is_owned_ordered_and_deduplicates_whole_batches() { + let first = PolicyFailureResult { + outcome: u32::MAX, + details: vec![PolicyFailureDetail { + requested_value: u64::MAX, + resource: Some(vec![0xd800]), + ..Default::default() + }], + ..Default::default() + }; + let mut second = first.clone(); + second.details[0].resource = Some(Vec::new()); + let old = PolicyFailures { + results: vec![first.clone(), first.clone()], + ..Default::default() + }; + let new = PolicyFailures { + results: vec![first.clone(), second.clone(), second.clone()], + ..Default::default() + }; + let old_snapshot = old.clone(); + let new_snapshot = new.clone(); + let merged = old.merged_with(&new).unwrap(); + assert_eq!(merged.results, [first, second]); + assert_eq!(old, old_snapshot); + assert_eq!(new, new_snapshot); + assert_eq!( + PolicyFailures::from_json(&merged.to_json().unwrap()).unwrap(), + merged, + ); + assert_eq!(merged.merged_with(&merged).unwrap(), merged); + } + + #[test] + fn policy_failures_merge_preserves_terminal_data_but_never_truncates_overflow() { + let terminal = PolicyFailures { + results: (0..=MAX_RESULTS as u32) + .map(|outcome| PolicyFailureResult { + outcome, + ..Default::default() + }) + .collect(), + ..Default::default() + }; + let new = PolicyFailures { + results: vec![PolicyFailureResult { + outcome: u32::MAX, + ..Default::default() + }], + ..Default::default() + }; + let snapshot = terminal.clone(); + terminal.to_json().unwrap(); + assert!(terminal.validate().is_err()); + assert!(terminal.merged_with(&new).is_err()); + assert_eq!(terminal, snapshot); + assert_eq!(new.results.len(), 1); + assert_eq!( + terminal.merged_with(&PolicyFailures::default()).unwrap(), + terminal, + ); + let invalid = PolicyFailures { + format_version: 2, + ..Default::default() + }; + assert!(invalid.merged_with(&new).is_err()); + assert!(new.merged_with(&invalid).is_err()); + } +} diff --git a/src/mxc-sdk/src/core/mxc_common/policy_preparation.rs b/src/mxc-sdk/src/core/mxc_common/policy_preparation.rs new file mode 100644 index 0000000000..5e4bcd8763 --- /dev/null +++ b/src/mxc-sdk/src/core/mxc_common/policy_preparation.rs @@ -0,0 +1,330 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Owned candidate-request and change data for policy preparation. + +use crate::mxc_common::mxc_error::MxcError; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub(crate) const MAX_REQUEST_BYTES: usize = 8 * 1024 * 1024; +pub(crate) const MAX_RESULT_BYTES: usize = 32 * 1024 * 1024; +pub(crate) const MAX_CHANGES: usize = 4096; + +/// How a preparation change affects a JSON location. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +#[non_exhaustive] +pub enum PolicyChangeOperation { + /// The location was absent and is added. + Add, + /// The location was present and is removed. + Remove, + /// A present value is replaced, including a present JSON null. + Replace, +} + +/// One change to the caller's MXC request JSON. +/// +/// `path` is a JSON Pointer. Both values are present in the change JSON: +/// `before` is null for Add and `after` is null for Remove. The operation +/// distinguishes these absent locations from a replaced, present JSON null. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[non_exhaustive] +pub struct PolicyChange { + /// Addition, removal or replacement of this location. + pub operation: PolicyChangeOperation, + /// JSON Pointer identifying the changed location. + pub path: String, + /// Original value, or null for an added location. + #[serde(deserialize_with = "required_value")] + pub before: Value, + /// Candidate value, or null for a removed location. + #[serde(deserialize_with = "required_value")] + pub after: Value, +} + +// The field deserializer rejects absence instead of treating a missing Value as JSON null. +fn required_value<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result { + Value::deserialize(deserializer) +} + +/// A candidate request, not a created container or policy approval. +/// +/// Inspect, accept, edit or reject `request_json`, then submit the chosen JSON +/// through an ordinary strict creation API. Preparation owns no live handles. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[non_exhaustive] +pub struct PreparedPolicyRequest { + /// Complete MXC request JSON in the caller's original contract. + pub request_json: String, + /// Structured changes from the original JSON to this candidate. + pub changes: Vec, +} + +impl PreparedPolicyRequest { + pub(crate) fn validate(&self) -> Result<(), String> { + if self.changes.len() > MAX_CHANGES { + return Err("policy preparation exceeds its change limit".into()); + } + if self.request_json.len() > MAX_RESULT_BYTES { + return Err("policy preparation exceeds its result byte limit".into()); + } + if !serde_json::from_str::(&self.request_json) + .map_err(|_| "prepared request is not valid JSON".to_owned())? + .is_object() + { + return Err("prepared request must be a JSON object".into()); + } + for change in &self.changes { + if (!change.path.is_empty() && !change.path.starts_with('/')) + || change + .path + .split('~') + .skip(1) + .any(|tail| !tail.starts_with('0') && !tail.starts_with('1')) + || matches!(change.operation, PolicyChangeOperation::Add) + && !change.before.is_null() + || matches!(change.operation, PolicyChangeOperation::Remove) + && !change.after.is_null() + { + return Err("invalid policy preparation change".into()); + } + } + serde_json::to_writer(&mut ResultSizeCounter::default(), self) + .map_err(|_| "policy preparation exceeds its result byte limit".to_owned()) + } + + pub(crate) fn parse(json: &str) -> Result { + if json.len() > MAX_RESULT_BYTES { + return Err("policy preparation exceeds its result byte limit".into()); + } + let result: Self = + serde_json::from_str(json).map_err(|_| "invalid policy preparation JSON".to_owned())?; + result.validate()?; + Ok(result) + } +} + +/// Append the changes from `before` to `after` at JSON Pointer `path`. +/// Objects are compared member by member; any other differing value, +/// including an array, is one change. +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +pub(crate) fn collect_changes( + path: &str, + before: Option<&Value>, + after: Option<&Value>, + changes: &mut Vec, +) -> Result<(), MxcError> { + if before == after { + return Ok(()); + } + if let (Some(Value::Object(old)), Some(Value::Object(new))) = (before, after) { + let keys: std::collections::BTreeSet<_> = old.keys().chain(new.keys()).collect(); + for key in keys { + let pointer = format!("{path}/{}", key.replace('~', "~0").replace('/', "~1")); + collect_changes(&pointer, old.get(key), new.get(key), changes)?; + } + return Ok(()); + } + if changes.len() >= MAX_CHANGES { + return Err(MxcError::policy_validation( + "policy preparation exceeds its change limit", + )); + } + changes.push(PolicyChange { + operation: match (before, after) { + (None, Some(_)) => PolicyChangeOperation::Add, + (Some(_), None) => PolicyChangeOperation::Remove, + _ => PolicyChangeOperation::Replace, + }, + path: path.to_owned(), + before: before.cloned().unwrap_or(Value::Null), + after: after.cloned().unwrap_or(Value::Null), + }); + Ok(()) +} + +#[derive(Default)] +struct ResultSizeCounter(usize); + +impl std::io::Write for ResultSizeCounter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0 = self.0.saturating_add(bytes.len()); + if self.0 > MAX_RESULT_BYTES { + return Err(std::io::Error::other( + "policy preparation exceeds its result byte limit", + )); + } + + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn preparation_changes_describe_exact_json_values_and_pointer_locations() { + let before = json!({ + "version": "1.0.0", + "a/b": { "~field": null, "removed": null }, + "array": ["first", "second"], + "unchanged": { "optional": null } + }); + let after = json!({ + "version": "1.0.0", + "a/b": { "~field": false, "added": null }, + "array": ["first"], + "unchanged": { "optional": null } + }); + let mut changes = Vec::new(); + collect_changes("", Some(&before), Some(&after), &mut changes).unwrap(); + assert_eq!(changes.len(), 4); + for change in &changes { + match change.operation { + PolicyChangeOperation::Add => { + assert!(before.pointer(&change.path).is_none()); + assert_eq!(change.before, Value::Null); + assert_eq!(after.pointer(&change.path), Some(&change.after)); + } + PolicyChangeOperation::Remove => { + assert_eq!(before.pointer(&change.path), Some(&change.before)); + assert!(after.pointer(&change.path).is_none()); + assert_eq!(change.after, Value::Null); + } + PolicyChangeOperation::Replace => { + assert_eq!(before.pointer(&change.path), Some(&change.before)); + assert_eq!(after.pointer(&change.path), Some(&change.after)); + } + } + } + let null_replacement = changes + .iter() + .find(|change| change.path == "/a~1b/~0field") + .unwrap(); + assert_eq!(null_replacement.operation, PolicyChangeOperation::Replace); + assert_eq!(null_replacement.before, Value::Null); + let array = changes + .iter() + .find(|change| change.path == "/array") + .unwrap(); + assert_eq!(array.before, before["array"]); + assert_eq!(array.after, after["array"]); + assert!(!changes + .iter() + .any(|change| change.path.starts_with("/unchanged"))); + } + + #[test] + fn preparation_changes_exceeding_the_budget_fail_instead_of_returning_partial_data() { + let before = json!({}); + let mut after = serde_json::Map::new(); + for index in 0..MAX_CHANGES { + after.insert(index.to_string(), Value::Bool(true)); + } + let mut changes = Vec::new(); + collect_changes( + "", + Some(&before), + Some(&Value::Object(after.clone())), + &mut changes, + ) + .unwrap(); + after.insert("overflow".into(), Value::Bool(true)); + let error = collect_changes( + "", + Some(&before), + Some(&Value::Object(after)), + &mut Vec::new(), + ) + .unwrap_err(); + assert_eq!( + error.code, + crate::mxc_common::mxc_error::MxcErrorCode::PolicyValidation + ); + assert_eq!(error.message, "policy preparation exceeds its change limit"); + } + + #[test] + fn preparation_storage_requires_both_values_and_preserves_real_nulls() { + let original = json!({ + "requestJson": "{\"a\":null}", + "changes": [ + {"operation":"add","path":"/a~1b","before":null,"after":null}, + {"operation":"remove","path":"/removed","before":null,"after":null}, + {"operation":"replace","path":"/a","before":1,"after":null} + ] + }); + let stored = PreparedPolicyRequest::from_json(&original.to_string()).unwrap(); + assert_eq!( + serde_json::from_str::(&stored.to_json().unwrap()).unwrap(), + original, + ); + for index in 0..3 { + for field in ["before", "after"] { + let mut incomplete = original.clone(); + incomplete["changes"][index] + .as_object_mut() + .unwrap() + .remove(field); + assert!(PreparedPolicyRequest::from_json(&incomplete.to_string()).is_err()); + } + } + for (path, operation, before, after) in [ + ("/a~2b", "replace", Value::Null, Value::Null), + ("not-a-pointer", "replace", Value::Null, Value::Null), + ("/trailing~", "replace", Value::Null, Value::Null), + ("/a", "add", json!(1), Value::Null), + ("/a", "remove", Value::Null, json!(1)), + ("/a", "unknown", Value::Null, Value::Null), + ] { + let invalid = json!({ + "requestJson": "{}", + "changes": [{"operation":operation,"path":path,"before":before,"after":after}], + }); + assert!(PreparedPolicyRequest::from_json(&invalid.to_string()).is_err()); + } + for request_json in ["[]", "1", "null", "\"{}\""] { + let non_object = json!({"requestJson": request_json, "changes": []}); + assert!(PreparedPolicyRequest::from_json(&non_object.to_string()).is_err()); + } + } + + #[test] + fn preparation_storage_limits_are_whole_result_limits() { + let mut prepared = PreparedPolicyRequest { + request_json: "{}".into(), + changes: (0..MAX_CHANGES) + .map(|index| PolicyChange { + operation: PolicyChangeOperation::Add, + path: format!("/{index}"), + before: Value::Null, + after: json!(index), + }) + .collect(), + }; + assert_eq!( + PreparedPolicyRequest::from_json(&prepared.to_json().unwrap()).unwrap(), + prepared, + ); + prepared.changes.push(prepared.changes[0].clone()); + assert!(prepared.to_json().is_err()); + prepared.changes.clear(); + prepared.request_json = json!({"data": "x".repeat(MAX_REQUEST_BYTES)}).to_string(); + prepared + .to_json() + .expect("candidate storage is bounded by the result limit"); + prepared.request_json = json!({"data": "x".repeat(MAX_RESULT_BYTES)}).to_string(); + assert!(prepared.to_json().is_err()); + assert!(PreparedPolicyRequest::from_json(&" ".repeat(MAX_RESULT_BYTES + 1)).is_err()); + } +} diff --git a/src/mxc-sdk/src/core/mxc_common/script_runner.rs b/src/mxc-sdk/src/core/mxc_common/script_runner.rs index 2dec092185..a6491a571b 100644 --- a/src/mxc-sdk/src/core/mxc_common/script_runner.rs +++ b/src/mxc-sdk/src/core/mxc_common/script_runner.rs @@ -168,6 +168,9 @@ fn backend_error_envelope_line(response: &ScriptResponse) -> Option { return None; } + if let Some(error) = response.structured_error() { + return serde_json::to_string(&serde_json::json!({ "error": error })).ok(); + } let mut envelope = serde_json::json!({ "error": { "code": response.failure_phase.error_code().as_str(), diff --git a/src/mxc-sdk/src/core/mxc_common/splice.rs b/src/mxc-sdk/src/core/mxc_common/splice.rs index 9e4661b725..be26dba6e2 100644 --- a/src/mxc-sdk/src/core/mxc_common/splice.rs +++ b/src/mxc-sdk/src/core/mxc_common/splice.rs @@ -59,6 +59,46 @@ impl<'de> Deserialize<'de> for RawObject<'de> { } } +/// Serializes the `updated` object, copying the source text of each top-level +/// member of `source` whose value is unchanged. Re-serializing those values +/// would round numbers and collapse duplicate keys in caller annotations. +pub(crate) fn serialize_preserving_unchanged_members( + source: &str, + original: &serde_json::Map, + updated: &serde_json::Map, +) -> Option { + let root: RawObject<'_> = serde_json::from_str(source).ok()?; + let mut emitted = std::collections::HashSet::new(); + let mut members = Vec::with_capacity(updated.len()); + for member in &root.members { + if !emitted.insert(member.key.as_str()) { + return None; + } + let Some(value) = updated.get(&member.key) else { + continue; + }; + let value = if original.get(&member.key) == Some(value) { + member.value.get().to_owned() + } else { + serde_json::to_string(value).ok()? + }; + members.push(format!( + "{}:{value}", + serde_json::to_string(&member.key).ok()? + )); + } + for (key, value) in updated { + if !emitted.contains(key.as_str()) { + members.push(format!( + "{}:{}", + serde_json::to_string(key).ok()?, + serde_json::to_string(value).ok()? + )); + } + } + Some(format!("{{{}}}", members.join(","))) +} + pub(crate) struct CommandSource<'a> { json: &'a str, root: RawObject<'a>, diff --git a/src/mxc-sdk/src/core/mxc_engine/dispatch.rs b/src/mxc-sdk/src/core/mxc_engine/dispatch.rs index 2e1aa6ddd3..cbb6ba4d2a 100644 --- a/src/mxc-sdk/src/core/mxc_engine/dispatch.rs +++ b/src/mxc-sdk/src/core/mxc_engine/dispatch.rs @@ -148,22 +148,7 @@ pub fn spawn_pty_runner( /// `extended_error` detail into the message — rather than flattening /// everything to a generic `BackendError`. fn map_spawn_error(resp: ScriptResponse) -> MxcError { - use crate::mxc_common::models::FailurePhase; - - let mut message = resp.error_message; - if !resp.extended_error.is_empty() { - if message.is_empty() { - message = resp.extended_error; - } else { - message = format!("{message} ({})", resp.extended_error); - } - } - match resp.failure_phase { - FailurePhase::BackendUnavailable => MxcError::backend_unavailable(message), - // The request itself cannot be honored, so a blind retry will not help. - FailurePhase::Rejected => MxcError::policy_validation(message), - _ => MxcError::backend_error(message), - } + resp.into_mxc_error() } #[cfg(target_os = "linux")] diff --git a/src/mxc-sdk/src/core/mxc_engine/error.rs b/src/mxc-sdk/src/core/mxc_engine/error.rs index c9eaf5009a..759d19203a 100644 --- a/src/mxc-sdk/src/core/mxc_engine/error.rs +++ b/src/mxc-sdk/src/core/mxc_engine/error.rs @@ -92,9 +92,9 @@ impl From for ErrorCode { /// An error returned by SDK operations such as [`spawn`](crate::v1::spawn). /// /// `operation`, `native_code`, and `remediation` are top-level fields also -/// exposed by Node, .NET, and `mxc_ffi`. Backend-specific `details` are preserved -/// in Rust errors and executor JSON error envelopes. The C error structure -/// does not expose them. +/// exposed by Node, .NET, and `mxc_ffi`. Backend-specific `details` are +/// preserved in Rust errors, executor JSON error envelopes, and the C error +/// structure; Node and .NET expose them as `details` and `Details`. /// /// This type is `#[non_exhaustive]`. Use [`Error::new`] rather than a struct /// literal so future versions can add fields without breaking construction. @@ -215,6 +215,34 @@ impl Error { mod tests { use super::*; use crate::mxc_common::mxc_error::{ApiFailure as InnerFailure, MxcError}; + + #[test] + fn policy_failures_error_debug_does_not_render_structured_resources() { + let payload = + serde_json::json!({ "policyFailures": { "resource": "POLICY_RESOURCE_MARKER" } }); + let inner = MxcError::backend_error("Creation refused.").with_details(payload); + assert!(!format!("{inner:?}").contains("POLICY_RESOURCE_MARKER")); + let error = Error::from(inner); + assert!(!format!("{error:?}").contains("POLICY_RESOURCE_MARKER")); + assert!(error + .details + .as_deref() + .unwrap() + .get("policyFailures") + .is_some()); + } + + #[test] + fn policy_failures_details_survive_sdk_conversions() { + let payload = + serde_json::json!({ "policyFailures": { "formatVersion": 1, "results": [] } }); + let original = MxcError::backend_error("Creation refused.").with_details(payload.clone()); + let converted = Error::from(original); + assert_eq!(converted.details.as_deref(), Some(&payload)); + assert_eq!(converted.into_mxc_error().details, Some(payload)); + assert!(std::mem::size_of::() < 128); + } + /// The conversion carries the API detail across, rather than keeping only /// the code and message. /// diff --git a/src/mxc-sdk/src/core/mxc_engine/mod.rs b/src/mxc-sdk/src/core/mxc_engine/mod.rs index 0262860d6b..a502dc501d 100644 --- a/src/mxc-sdk/src/core/mxc_engine/mod.rs +++ b/src/mxc-sdk/src/core/mxc_engine/mod.rs @@ -34,6 +34,7 @@ mod dispatch; mod error; mod experimental; mod platform; +mod policy_preparation; mod probe; #[cfg(target_os = "windows")] mod request_probe; @@ -47,6 +48,7 @@ pub use error::{Error, ErrorCode}; #[cfg(all(target_os = "windows", feature = "isolation_session"))] pub use platform::isolation_session_available; pub use platform::{platform_support, BubblewrapNetworkSupport, PlatformSupport, ProxyEnforcement}; +pub(crate) use policy_preparation::prepare_policy_json; pub use probe::{available_backends, to_json_pretty, AvailableBackend, BackendCapability}; #[cfg(target_os = "windows")] pub use request_probe::{probe_execution_request, ProbeFacts, ProbeOutput, UiCapabilitySupport}; diff --git a/src/mxc-sdk/src/core/mxc_engine/policy_preparation.rs b/src/mxc-sdk/src/core/mxc_engine/policy_preparation.rs new file mode 100644 index 0000000000..b30b6c9c7e --- /dev/null +++ b/src/mxc-sdk/src/core/mxc_engine/policy_preparation.rs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Prepare ordinary request JSON without creating an environment or workload. + +use crate::mxc_common::logger::{Logger, Mode}; +use crate::mxc_common::models::ContainmentBackend; +use crate::mxc_common::mxc_error::MxcError; +use crate::mxc_common::policy_failures::PolicyFailures; +use crate::mxc_common::policy_preparation::{PreparedPolicyRequest, MAX_REQUEST_BYTES}; + +use super::Error; + +#[cfg(test)] +#[path = "policy_preparation_tests.rs"] +mod tests; + +pub(crate) fn prepare_policy_json( + json: &str, + failures: &PolicyFailures, + experimental: bool, +) -> Result { + if json.len() > MAX_REQUEST_BYTES { + return Err(MxcError::malformed_request( + "policy preparation request exceeds its byte limit", + ) + .into()); + } + failures.validate().map_err(MxcError::malformed_request)?; + let mut logger = Logger::new(Mode::Buffer); + let request = super::parse_one_shot_json(json, experimental, &mut logger).map_err(|error| { + Error::new( + error.code, + "policy preparation requires a valid create-and-run request in a registered JSON contract", + ) + })?; + if !cfg!(target_os = "windows") || request.containment != ContainmentBackend::ProcessContainer { + return Err(MxcError::unsupported_containment( + "policy preparation requires native Windows ProcessContainer", + ) + .into()); + } + #[cfg(target_os = "windows")] + { + let result = crate::process_container_common::policy_projection::prepare_candidate( + json, + &request, + failures, + |candidate| { + super::parse_one_shot_json(candidate, experimental, &mut Logger::new(Mode::Buffer)) + .ok() + }, + )?; + result.validate().map_err(MxcError::policy_validation)?; + Ok(result) + } + #[cfg(not(target_os = "windows"))] + { + Err(MxcError::unsupported_containment( + "policy preparation requires native Windows ProcessContainer", + ) + .into()) + } +} diff --git a/src/mxc-sdk/src/core/mxc_engine/policy_preparation_tests.rs b/src/mxc-sdk/src/core/mxc_engine/policy_preparation_tests.rs new file mode 100644 index 0000000000..da47a0ee91 --- /dev/null +++ b/src/mxc-sdk/src/core/mxc_engine/policy_preparation_tests.rs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::*; +use crate::v1::ErrorCode; +use serde_json::json; + +#[test] +fn preparation_rejects_invalid_or_oversized_input_without_echoing_request_data() { + let failures = PolicyFailures::default(); + for json in [ + r#"{"version":"1.0.0","process":{"commandLine":"INPUT_SECRET_MARKER"},"INPUT_SECRET_MARKER":true}"#, + r#"{"version":"1.0.0","process":{"commandLine":"INPUT_SECRET_MARKER"},"filesystem":{"readwritePaths":["C:\\INPUT_SECRET_MARKER\u0000"]}}"#, + r#"{"version":"999.0.0","process":{"commandLine":"INPUT_SECRET_MARKER"}}"#, + r#"{"version":"1.0.0","phase":"start","sandboxId":"iso:INPUT_SECRET_MARKER"}"#, + ] { + for experimental in [false, true] { + let error = prepare_policy_json(json, &failures, experimental).unwrap_err(); + assert_eq!(error.code, ErrorCode::MalformedRequest); + assert!(!error.to_string().contains("INPUT_SECRET_MARKER")); + assert!(!format!("{error:?}").contains("INPUT_SECRET_MARKER")); + } + } + let oversized = json!({ + "version":"1.0.0", "process": { + "commandLine": format!("INPUT_SECRET_MARKER{}", "x".repeat(MAX_REQUEST_BYTES)), + } + }) + .to_string(); + let error = prepare_policy_json(&oversized, &failures, false).unwrap_err(); + assert_eq!(error.code, ErrorCode::MalformedRequest); + assert!(error.message.contains("byte limit")); + assert!(!error.to_string().contains("INPUT_SECRET_MARKER")); + let unsupported = r#"{"version":"1.0.0","containment":"bubblewrap","process":{"commandLine":"must-not-run"}}"#; + assert_eq!( + prepare_policy_json(unsupported, &failures, false) + .unwrap_err() + .code, + ErrorCode::UnsupportedContainment, + ); +} diff --git a/src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/failure_class_generated.rs b/src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/failure_class_generated.rs new file mode 100644 index 0000000000..ebb56988d3 --- /dev/null +++ b/src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/failure_class_generated.rs @@ -0,0 +1,106 @@ +// automatically generated by the FlatBuffers compiler, do not modify +// @generated +extern crate alloc; +use super::*; +#[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." +)] +pub const ENUM_MIN_FAILURE_CLASS: u32 = 0; +#[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." +)] +pub const ENUM_MAX_FAILURE_CLASS: u32 = 3; +#[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." +)] +#[allow(non_camel_case_types)] +pub const ENUM_VALUES_FAILURE_CLASS: [FailureClass; 4] = [ + FailureClass::None, + FailureClass::Network, + FailureClass::Filesystem, + FailureClass::EnforcementMode, +]; + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +#[repr(transparent)] +pub struct FailureClass(pub u32); +#[allow(non_upper_case_globals)] +impl FailureClass { + pub const None: Self = Self(0); + pub const Network: Self = Self(1); + pub const Filesystem: Self = Self(2); + pub const EnforcementMode: Self = Self(3); + + pub const ENUM_MIN: u32 = 0; + pub const ENUM_MAX: u32 = 3; + pub const ENUM_VALUES: &'static [Self] = &[ + Self::None, + Self::Network, + Self::Filesystem, + Self::EnforcementMode, + ]; + /// Returns the variant's name or "" if unknown. + pub fn variant_name(self) -> Option<&'static str> { + match self { + Self::None => Some("None"), + Self::Network => Some("Network"), + Self::Filesystem => Some("Filesystem"), + Self::EnforcementMode => Some("EnforcementMode"), + _ => None, + } + } +} +impl ::core::fmt::Debug for FailureClass { + fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result { + if let Some(name) = self.variant_name() { + f.write_str(name) + } else { + f.write_fmt(format_args!("", self.0)) + } + } +} +impl<'a> ::flatbuffers::Follow<'a> for FailureClass { + type Inner = Self; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + let b = unsafe { ::flatbuffers::read_scalar_at::(buf, loc) }; + Self(b) + } +} + +impl ::flatbuffers::Push for FailureClass { + type Output = FailureClass; + #[inline] + unsafe fn push(&self, dst: &mut [u8], _written_len: usize) { + unsafe { ::flatbuffers::emplace_scalar::(dst, self.0) }; + } +} + +impl ::flatbuffers::EndianScalar for FailureClass { + type Scalar = u32; + #[inline] + fn to_little_endian(self) -> u32 { + self.0.to_le() + } + #[inline] + #[allow(clippy::wrong_self_convention)] + fn from_little_endian(v: u32) -> Self { + let b = u32::from_le(v); + Self(b) + } +} + +impl<'a> ::flatbuffers::Verifiable for FailureClass { + #[inline] + fn run_verifier( + v: &mut ::flatbuffers::Verifier, + pos: usize, + ) -> Result<(), ::flatbuffers::InvalidFlatbuffer> { + u32::run_verifier(v, pos) + } +} + +impl ::flatbuffers::SimpleToVerifyInSlice for FailureClass {} diff --git a/src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/failure_reason_generated.rs b/src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/failure_reason_generated.rs new file mode 100644 index 0000000000..b5410ef23b --- /dev/null +++ b/src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/failure_reason_generated.rs @@ -0,0 +1,110 @@ +// automatically generated by the FlatBuffers compiler, do not modify +// @generated +extern crate alloc; +use super::*; +#[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." +)] +pub const ENUM_MIN_FAILURE_REASON: u32 = 0; +#[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." +)] +pub const ENUM_MAX_FAILURE_REASON: u32 = 4; +#[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." +)] +#[allow(non_camel_case_types)] +pub const ENUM_VALUES_FAILURE_REASON: [FailureReason; 5] = [ + FailureReason::None, + FailureReason::CapabilityNotAllowed, + FailureReason::AccessExceedsCeiling, + FailureReason::DenyRequired, + FailureReason::RequestPathLimit, +]; + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +#[repr(transparent)] +pub struct FailureReason(pub u32); +#[allow(non_upper_case_globals)] +impl FailureReason { + pub const None: Self = Self(0); + pub const CapabilityNotAllowed: Self = Self(1); + pub const AccessExceedsCeiling: Self = Self(2); + pub const DenyRequired: Self = Self(3); + pub const RequestPathLimit: Self = Self(4); + + pub const ENUM_MIN: u32 = 0; + pub const ENUM_MAX: u32 = 4; + pub const ENUM_VALUES: &'static [Self] = &[ + Self::None, + Self::CapabilityNotAllowed, + Self::AccessExceedsCeiling, + Self::DenyRequired, + Self::RequestPathLimit, + ]; + /// Returns the variant's name or "" if unknown. + pub fn variant_name(self) -> Option<&'static str> { + match self { + Self::None => Some("None"), + Self::CapabilityNotAllowed => Some("CapabilityNotAllowed"), + Self::AccessExceedsCeiling => Some("AccessExceedsCeiling"), + Self::DenyRequired => Some("DenyRequired"), + Self::RequestPathLimit => Some("RequestPathLimit"), + _ => None, + } + } +} +impl ::core::fmt::Debug for FailureReason { + fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result { + if let Some(name) = self.variant_name() { + f.write_str(name) + } else { + f.write_fmt(format_args!("", self.0)) + } + } +} +impl<'a> ::flatbuffers::Follow<'a> for FailureReason { + type Inner = Self; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + let b = unsafe { ::flatbuffers::read_scalar_at::(buf, loc) }; + Self(b) + } +} + +impl ::flatbuffers::Push for FailureReason { + type Output = FailureReason; + #[inline] + unsafe fn push(&self, dst: &mut [u8], _written_len: usize) { + unsafe { ::flatbuffers::emplace_scalar::(dst, self.0) }; + } +} + +impl ::flatbuffers::EndianScalar for FailureReason { + type Scalar = u32; + #[inline] + fn to_little_endian(self) -> u32 { + self.0.to_le() + } + #[inline] + #[allow(clippy::wrong_self_convention)] + fn from_little_endian(v: u32) -> Self { + let b = u32::from_le(v); + Self(b) + } +} + +impl<'a> ::flatbuffers::Verifiable for FailureReason { + #[inline] + fn run_verifier( + v: &mut ::flatbuffers::Verifier, + pos: usize, + ) -> Result<(), ::flatbuffers::InvalidFlatbuffer> { + u32::run_verifier(v, pos) + } +} + +impl ::flatbuffers::SimpleToVerifyInSlice for FailureReason {} diff --git a/src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/policy_access_generated.rs b/src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/policy_access_generated.rs new file mode 100644 index 0000000000..ca117ebcde --- /dev/null +++ b/src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/policy_access_generated.rs @@ -0,0 +1,98 @@ +// automatically generated by the FlatBuffers compiler, do not modify +// @generated +extern crate alloc; +use super::*; +#[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." +)] +pub const ENUM_MIN_POLICY_ACCESS: u64 = 0; +#[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." +)] +pub const ENUM_MAX_POLICY_ACCESS: u64 = 2; +#[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." +)] +#[allow(non_camel_case_types)] +pub const ENUM_VALUES_POLICY_ACCESS: [PolicyAccess; 3] = [ + PolicyAccess::None, + PolicyAccess::ReadOnly, + PolicyAccess::ReadWrite, +]; + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +#[repr(transparent)] +pub struct PolicyAccess(pub u64); +#[allow(non_upper_case_globals)] +impl PolicyAccess { + pub const None: Self = Self(0); + pub const ReadOnly: Self = Self(1); + pub const ReadWrite: Self = Self(2); + + pub const ENUM_MIN: u64 = 0; + pub const ENUM_MAX: u64 = 2; + pub const ENUM_VALUES: &'static [Self] = &[Self::None, Self::ReadOnly, Self::ReadWrite]; + /// Returns the variant's name or "" if unknown. + pub fn variant_name(self) -> Option<&'static str> { + match self { + Self::None => Some("None"), + Self::ReadOnly => Some("ReadOnly"), + Self::ReadWrite => Some("ReadWrite"), + _ => None, + } + } +} +impl ::core::fmt::Debug for PolicyAccess { + fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result { + if let Some(name) = self.variant_name() { + f.write_str(name) + } else { + f.write_fmt(format_args!("", self.0)) + } + } +} +impl<'a> ::flatbuffers::Follow<'a> for PolicyAccess { + type Inner = Self; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + let b = unsafe { ::flatbuffers::read_scalar_at::(buf, loc) }; + Self(b) + } +} + +impl ::flatbuffers::Push for PolicyAccess { + type Output = PolicyAccess; + #[inline] + unsafe fn push(&self, dst: &mut [u8], _written_len: usize) { + unsafe { ::flatbuffers::emplace_scalar::(dst, self.0) }; + } +} + +impl ::flatbuffers::EndianScalar for PolicyAccess { + type Scalar = u64; + #[inline] + fn to_little_endian(self) -> u64 { + self.0.to_le() + } + #[inline] + #[allow(clippy::wrong_self_convention)] + fn from_little_endian(v: u64) -> Self { + let b = u64::from_le(v); + Self(b) + } +} + +impl<'a> ::flatbuffers::Verifiable for PolicyAccess { + #[inline] + fn run_verifier( + v: &mut ::flatbuffers::Verifier, + pos: usize, + ) -> Result<(), ::flatbuffers::InvalidFlatbuffer> { + u64::run_verifier(v, pos) + } +} + +impl ::flatbuffers::SimpleToVerifyInSlice for PolicyAccess {} diff --git a/src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/policy_detail_generated.rs b/src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/policy_detail_generated.rs new file mode 100644 index 0000000000..e1642ad3d7 --- /dev/null +++ b/src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/policy_detail_generated.rs @@ -0,0 +1,393 @@ +// automatically generated by the FlatBuffers compiler, do not modify +// @generated +extern crate alloc; +use super::*; +pub enum PolicyDetailOffset {} +#[derive(Copy, Clone, PartialEq)] + +pub struct PolicyDetail<'a> { + pub _tab: ::flatbuffers::Table<'a>, +} + +impl<'a> ::flatbuffers::Follow<'a> for PolicyDetail<'a> { + type Inner = PolicyDetail<'a>; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + Self { + _tab: unsafe { ::flatbuffers::Table::new(buf, loc) }, + } + } +} + +impl<'a> PolicyDetail<'a> { + pub const VT_FAILURE_CLASS: ::flatbuffers::VOffsetT = 4; + pub const VT_FAILURE_REASON: ::flatbuffers::VOffsetT = 6; + pub const VT_REQUIRED_ACTION: ::flatbuffers::VOffsetT = 8; + pub const VT_RESOURCE_KIND: ::flatbuffers::VOffsetT = 10; + pub const VT_VALUE_KIND: ::flatbuffers::VOffsetT = 12; + pub const VT_FLAGS: ::flatbuffers::VOffsetT = 14; + pub const VT_REQUESTED_VALUE: ::flatbuffers::VOffsetT = 16; + pub const VT_REQUIRED_VALUE: ::flatbuffers::VOffsetT = 18; + pub const VT_RESOURCE: ::flatbuffers::VOffsetT = 20; + + #[inline] + pub unsafe fn init_from_table(table: ::flatbuffers::Table<'a>) -> Self { + PolicyDetail { _tab: table } + } + #[allow(unused_mut)] + pub fn create< + 'bldr: 'args, + 'args: 'mut_bldr, + 'mut_bldr, + A: ::flatbuffers::Allocator + 'bldr, + >( + _fbb: &'mut_bldr mut ::flatbuffers::FlatBufferBuilder<'bldr, A>, + args: &'args PolicyDetailArgs<'args>, + ) -> ::flatbuffers::WIPOffset> { + let mut builder = PolicyDetailBuilder::new(_fbb); + builder.add_required_value(args.required_value); + builder.add_requested_value(args.requested_value); + if let Some(x) = args.resource { + builder.add_resource(x); + } + builder.add_flags(args.flags); + builder.add_value_kind(args.value_kind); + builder.add_resource_kind(args.resource_kind); + builder.add_required_action(args.required_action); + builder.add_failure_reason(args.failure_reason); + builder.add_failure_class(args.failure_class); + builder.finish() + } + + pub fn unpack(&self) -> PolicyDetailT { + let failure_class = self.failure_class(); + let failure_reason = self.failure_reason(); + let required_action = self.required_action(); + let resource_kind = self.resource_kind(); + let value_kind = self.value_kind(); + let flags = self.flags(); + let requested_value = self.requested_value(); + let required_value = self.required_value(); + let resource = self.resource().map(|x| x.into_iter().collect()); + PolicyDetailT { + failure_class, + failure_reason, + required_action, + resource_kind, + value_kind, + flags, + requested_value, + required_value, + resource, + } + } + + #[inline] + pub fn failure_class(&self) -> FailureClass { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::(PolicyDetail::VT_FAILURE_CLASS, Some(FailureClass::None)) + .unwrap() + } + } + #[inline] + pub fn failure_reason(&self) -> FailureReason { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::(PolicyDetail::VT_FAILURE_REASON, Some(FailureReason::None)) + .unwrap() + } + } + #[inline] + pub fn required_action(&self) -> RequiredAction { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::(PolicyDetail::VT_REQUIRED_ACTION, Some(RequiredAction::None)) + .unwrap() + } + } + #[inline] + pub fn resource_kind(&self) -> ResourceKind { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::(PolicyDetail::VT_RESOURCE_KIND, Some(ResourceKind::None)) + .unwrap() + } + } + #[inline] + pub fn value_kind(&self) -> ValueKind { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::(PolicyDetail::VT_VALUE_KIND, Some(ValueKind::None)) + .unwrap() + } + } + #[inline] + pub fn flags(&self) -> u32 { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::(PolicyDetail::VT_FLAGS, Some(0)) + .unwrap() + } + } + #[inline] + pub fn requested_value(&self) -> u64 { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::(PolicyDetail::VT_REQUESTED_VALUE, Some(0)) + .unwrap() + } + } + #[inline] + pub fn required_value(&self) -> u64 { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::(PolicyDetail::VT_REQUIRED_VALUE, Some(0)) + .unwrap() + } + } + #[inline] + pub fn resource(&self) -> Option<::flatbuffers::Vector<'a, u16>> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'a, u16>>>( + PolicyDetail::VT_RESOURCE, + None, + ) + } + } +} + +impl ::flatbuffers::Verifiable for PolicyDetail<'_> { + #[inline] + fn run_verifier( + v: &mut ::flatbuffers::Verifier, + pos: usize, + ) -> Result<(), ::flatbuffers::InvalidFlatbuffer> { + v.visit_table(pos)? + .visit_field::("failure_class", Self::VT_FAILURE_CLASS, false)? + .visit_field::("failure_reason", Self::VT_FAILURE_REASON, false)? + .visit_field::("required_action", Self::VT_REQUIRED_ACTION, false)? + .visit_field::("resource_kind", Self::VT_RESOURCE_KIND, false)? + .visit_field::("value_kind", Self::VT_VALUE_KIND, false)? + .visit_field::("flags", Self::VT_FLAGS, false)? + .visit_field::("requested_value", Self::VT_REQUESTED_VALUE, false)? + .visit_field::("required_value", Self::VT_REQUIRED_VALUE, false)? + .visit_field::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'_, u16>>>( + "resource", + Self::VT_RESOURCE, + false, + )? + .finish(); + Ok(()) + } +} +pub struct PolicyDetailArgs<'a> { + pub failure_class: FailureClass, + pub failure_reason: FailureReason, + pub required_action: RequiredAction, + pub resource_kind: ResourceKind, + pub value_kind: ValueKind, + pub flags: u32, + pub requested_value: u64, + pub required_value: u64, + pub resource: Option<::flatbuffers::WIPOffset<::flatbuffers::Vector<'a, u16>>>, +} +impl<'a> Default for PolicyDetailArgs<'a> { + #[inline] + fn default() -> Self { + PolicyDetailArgs { + failure_class: FailureClass::None, + failure_reason: FailureReason::None, + required_action: RequiredAction::None, + resource_kind: ResourceKind::None, + value_kind: ValueKind::None, + flags: 0, + requested_value: 0, + required_value: 0, + resource: None, + } + } +} + +pub struct PolicyDetailBuilder<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> { + fbb_: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>, + start_: ::flatbuffers::WIPOffset<::flatbuffers::TableUnfinishedWIPOffset>, +} +impl<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> PolicyDetailBuilder<'a, 'b, A> { + #[inline] + pub fn add_failure_class(&mut self, failure_class: FailureClass) { + self.fbb_.push_slot::( + PolicyDetail::VT_FAILURE_CLASS, + failure_class, + FailureClass::None, + ); + } + #[inline] + pub fn add_failure_reason(&mut self, failure_reason: FailureReason) { + self.fbb_.push_slot::( + PolicyDetail::VT_FAILURE_REASON, + failure_reason, + FailureReason::None, + ); + } + #[inline] + pub fn add_required_action(&mut self, required_action: RequiredAction) { + self.fbb_.push_slot::( + PolicyDetail::VT_REQUIRED_ACTION, + required_action, + RequiredAction::None, + ); + } + #[inline] + pub fn add_resource_kind(&mut self, resource_kind: ResourceKind) { + self.fbb_.push_slot::( + PolicyDetail::VT_RESOURCE_KIND, + resource_kind, + ResourceKind::None, + ); + } + #[inline] + pub fn add_value_kind(&mut self, value_kind: ValueKind) { + self.fbb_ + .push_slot::(PolicyDetail::VT_VALUE_KIND, value_kind, ValueKind::None); + } + #[inline] + pub fn add_flags(&mut self, flags: u32) { + self.fbb_.push_slot::(PolicyDetail::VT_FLAGS, flags, 0); + } + #[inline] + pub fn add_requested_value(&mut self, requested_value: u64) { + self.fbb_ + .push_slot::(PolicyDetail::VT_REQUESTED_VALUE, requested_value, 0); + } + #[inline] + pub fn add_required_value(&mut self, required_value: u64) { + self.fbb_ + .push_slot::(PolicyDetail::VT_REQUIRED_VALUE, required_value, 0); + } + #[inline] + pub fn add_resource( + &mut self, + resource: ::flatbuffers::WIPOffset<::flatbuffers::Vector<'b, u16>>, + ) { + self.fbb_ + .push_slot_always::<::flatbuffers::WIPOffset<_>>(PolicyDetail::VT_RESOURCE, resource); + } + #[inline] + pub fn new( + _fbb: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>, + ) -> PolicyDetailBuilder<'a, 'b, A> { + let start = _fbb.start_table(); + PolicyDetailBuilder { + fbb_: _fbb, + start_: start, + } + } + #[inline] + pub fn finish(self) -> ::flatbuffers::WIPOffset> { + let o = self.fbb_.end_table(self.start_); + ::flatbuffers::WIPOffset::new(o.value()) + } +} + +impl ::core::fmt::Debug for PolicyDetail<'_> { + fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + let mut ds = f.debug_struct("PolicyDetail"); + ds.field("failure_class", &self.failure_class()); + ds.field("failure_reason", &self.failure_reason()); + ds.field("required_action", &self.required_action()); + ds.field("resource_kind", &self.resource_kind()); + ds.field("value_kind", &self.value_kind()); + ds.field("flags", &self.flags()); + ds.field("requested_value", &self.requested_value()); + ds.field("required_value", &self.required_value()); + ds.field("resource", &self.resource()); + ds.finish() + } +} +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq)] +pub struct PolicyDetailT { + pub failure_class: FailureClass, + pub failure_reason: FailureReason, + pub required_action: RequiredAction, + pub resource_kind: ResourceKind, + pub value_kind: ValueKind, + pub flags: u32, + pub requested_value: u64, + pub required_value: u64, + pub resource: Option>, +} +impl Default for PolicyDetailT { + fn default() -> Self { + Self { + failure_class: FailureClass::None, + failure_reason: FailureReason::None, + required_action: RequiredAction::None, + resource_kind: ResourceKind::None, + value_kind: ValueKind::None, + flags: 0, + requested_value: 0, + required_value: 0, + resource: None, + } + } +} +impl PolicyDetailT { + pub fn pack<'b, A: ::flatbuffers::Allocator + 'b>( + &self, + _fbb: &mut ::flatbuffers::FlatBufferBuilder<'b, A>, + ) -> ::flatbuffers::WIPOffset> { + let failure_class = self.failure_class; + let failure_reason = self.failure_reason; + let required_action = self.required_action; + let resource_kind = self.resource_kind; + let value_kind = self.value_kind; + let flags = self.flags; + let requested_value = self.requested_value; + let required_value = self.required_value; + let resource = self.resource.as_ref().map(|x| _fbb.create_vector(x)); + PolicyDetail::create( + _fbb, + &PolicyDetailArgs { + failure_class, + failure_reason, + required_action, + resource_kind, + value_kind, + flags, + requested_value, + required_value, + resource, + }, + ) + } +} diff --git a/src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/policy_outcome_generated.rs b/src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/policy_outcome_generated.rs new file mode 100644 index 0000000000..0e0393debc --- /dev/null +++ b/src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/policy_outcome_generated.rs @@ -0,0 +1,99 @@ +// automatically generated by the FlatBuffers compiler, do not modify +// @generated +extern crate alloc; +use super::*; +#[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." +)] +pub const ENUM_MIN_POLICY_OUTCOME: u32 = 0; +#[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." +)] +pub const ENUM_MAX_POLICY_OUTCOME: u32 = 2; +#[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." +)] +#[allow(non_camel_case_types)] +pub const ENUM_VALUES_POLICY_OUTCOME: [PolicyOutcome; 3] = [ + PolicyOutcome::Unknown, + PolicyOutcome::Blocked, + PolicyOutcome::EvaluationFailed, +]; + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +#[repr(transparent)] +pub struct PolicyOutcome(pub u32); +#[allow(non_upper_case_globals)] +impl PolicyOutcome { + pub const Unknown: Self = Self(0); + pub const Blocked: Self = Self(1); + pub const EvaluationFailed: Self = Self(2); + + pub const ENUM_MIN: u32 = 0; + pub const ENUM_MAX: u32 = 2; + pub const ENUM_VALUES: &'static [Self] = + &[Self::Unknown, Self::Blocked, Self::EvaluationFailed]; + /// Returns the variant's name or "" if unknown. + pub fn variant_name(self) -> Option<&'static str> { + match self { + Self::Unknown => Some("Unknown"), + Self::Blocked => Some("Blocked"), + Self::EvaluationFailed => Some("EvaluationFailed"), + _ => None, + } + } +} +impl ::core::fmt::Debug for PolicyOutcome { + fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result { + if let Some(name) = self.variant_name() { + f.write_str(name) + } else { + f.write_fmt(format_args!("", self.0)) + } + } +} +impl<'a> ::flatbuffers::Follow<'a> for PolicyOutcome { + type Inner = Self; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + let b = unsafe { ::flatbuffers::read_scalar_at::(buf, loc) }; + Self(b) + } +} + +impl ::flatbuffers::Push for PolicyOutcome { + type Output = PolicyOutcome; + #[inline] + unsafe fn push(&self, dst: &mut [u8], _written_len: usize) { + unsafe { ::flatbuffers::emplace_scalar::(dst, self.0) }; + } +} + +impl ::flatbuffers::EndianScalar for PolicyOutcome { + type Scalar = u32; + #[inline] + fn to_little_endian(self) -> u32 { + self.0.to_le() + } + #[inline] + #[allow(clippy::wrong_self_convention)] + fn from_little_endian(v: u32) -> Self { + let b = u32::from_le(v); + Self(b) + } +} + +impl<'a> ::flatbuffers::Verifiable for PolicyOutcome { + #[inline] + fn run_verifier( + v: &mut ::flatbuffers::Verifier, + pos: usize, + ) -> Result<(), ::flatbuffers::InvalidFlatbuffer> { + u32::run_verifier(v, pos) + } +} + +impl ::flatbuffers::SimpleToVerifyInSlice for PolicyOutcome {} diff --git a/src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/policy_result_flags_generated.rs b/src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/policy_result_flags_generated.rs new file mode 100644 index 0000000000..c854a2e161 --- /dev/null +++ b/src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/policy_result_flags_generated.rs @@ -0,0 +1,106 @@ +// automatically generated by the FlatBuffers compiler, do not modify +// @generated +extern crate alloc; +use super::*; +#[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." +)] +pub const ENUM_MIN_POLICY_RESULT_FLAGS: u32 = 0; +#[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." +)] +pub const ENUM_MAX_POLICY_RESULT_FLAGS: u32 = 4; +#[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." +)] +#[allow(non_camel_case_types)] +pub const ENUM_VALUES_POLICY_RESULT_FLAGS: [PolicyResultFlags; 4] = [ + PolicyResultFlags::None, + PolicyResultFlags::HasActionableDetails, + PolicyResultFlags::ResourceComplete, + PolicyResultFlags::ResourceUnavailable, +]; + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +#[repr(transparent)] +pub struct PolicyResultFlags(pub u32); +#[allow(non_upper_case_globals)] +impl PolicyResultFlags { + pub const None: Self = Self(0); + pub const HasActionableDetails: Self = Self(1); + pub const ResourceComplete: Self = Self(2); + pub const ResourceUnavailable: Self = Self(4); + + pub const ENUM_MIN: u32 = 0; + pub const ENUM_MAX: u32 = 4; + pub const ENUM_VALUES: &'static [Self] = &[ + Self::None, + Self::HasActionableDetails, + Self::ResourceComplete, + Self::ResourceUnavailable, + ]; + /// Returns the variant's name or "" if unknown. + pub fn variant_name(self) -> Option<&'static str> { + match self { + Self::None => Some("None"), + Self::HasActionableDetails => Some("HasActionableDetails"), + Self::ResourceComplete => Some("ResourceComplete"), + Self::ResourceUnavailable => Some("ResourceUnavailable"), + _ => None, + } + } +} +impl ::core::fmt::Debug for PolicyResultFlags { + fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result { + if let Some(name) = self.variant_name() { + f.write_str(name) + } else { + f.write_fmt(format_args!("", self.0)) + } + } +} +impl<'a> ::flatbuffers::Follow<'a> for PolicyResultFlags { + type Inner = Self; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + let b = unsafe { ::flatbuffers::read_scalar_at::(buf, loc) }; + Self(b) + } +} + +impl ::flatbuffers::Push for PolicyResultFlags { + type Output = PolicyResultFlags; + #[inline] + unsafe fn push(&self, dst: &mut [u8], _written_len: usize) { + unsafe { ::flatbuffers::emplace_scalar::(dst, self.0) }; + } +} + +impl ::flatbuffers::EndianScalar for PolicyResultFlags { + type Scalar = u32; + #[inline] + fn to_little_endian(self) -> u32 { + self.0.to_le() + } + #[inline] + #[allow(clippy::wrong_self_convention)] + fn from_little_endian(v: u32) -> Self { + let b = u32::from_le(v); + Self(b) + } +} + +impl<'a> ::flatbuffers::Verifiable for PolicyResultFlags { + #[inline] + fn run_verifier( + v: &mut ::flatbuffers::Verifier, + pos: usize, + ) -> Result<(), ::flatbuffers::InvalidFlatbuffer> { + u32::run_verifier(v, pos) + } +} + +impl ::flatbuffers::SimpleToVerifyInSlice for PolicyResultFlags {} diff --git a/src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/policy_result_generated.rs b/src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/policy_result_generated.rs new file mode 100644 index 0000000000..4d6fd20476 --- /dev/null +++ b/src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/policy_result_generated.rs @@ -0,0 +1,352 @@ +// automatically generated by the FlatBuffers compiler, do not modify +// @generated +extern crate alloc; +use super::*; +pub enum PolicyResultOffset {} +#[derive(Copy, Clone, PartialEq)] + +pub struct PolicyResult<'a> { + pub _tab: ::flatbuffers::Table<'a>, +} + +impl<'a> ::flatbuffers::Follow<'a> for PolicyResult<'a> { + type Inner = PolicyResult<'a>; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + Self { + _tab: unsafe { ::flatbuffers::Table::new(buf, loc) }, + } + } +} + +impl<'a> PolicyResult<'a> { + pub const VT_VERSION: ::flatbuffers::VOffsetT = 4; + pub const VT_MINIMUM_READER_VERSION: ::flatbuffers::VOffsetT = 6; + pub const VT_OUTCOME: ::flatbuffers::VOffsetT = 8; + pub const VT_DETAILS: ::flatbuffers::VOffsetT = 10; + + #[inline] + pub unsafe fn init_from_table(table: ::flatbuffers::Table<'a>) -> Self { + PolicyResult { _tab: table } + } + #[allow(unused_mut)] + pub fn create< + 'bldr: 'args, + 'args: 'mut_bldr, + 'mut_bldr, + A: ::flatbuffers::Allocator + 'bldr, + >( + _fbb: &'mut_bldr mut ::flatbuffers::FlatBufferBuilder<'bldr, A>, + args: &'args PolicyResultArgs<'args>, + ) -> ::flatbuffers::WIPOffset> { + let mut builder = PolicyResultBuilder::new(_fbb); + if let Some(x) = args.details { + builder.add_details(x); + } + builder.add_outcome(args.outcome); + builder.add_minimum_reader_version(args.minimum_reader_version); + builder.add_version(args.version); + builder.finish() + } + + pub fn unpack(&self) -> PolicyResultT { + let version = self.version(); + let minimum_reader_version = self.minimum_reader_version(); + let outcome = self.outcome(); + let details = self + .details() + .map(|x| x.iter().map(|t| t.unpack()).collect()); + PolicyResultT { + version, + minimum_reader_version, + outcome, + details, + } + } + + #[inline] + pub fn version(&self) -> u32 { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::(PolicyResult::VT_VERSION, Some(0)) + .unwrap() + } + } + #[inline] + pub fn minimum_reader_version(&self) -> u32 { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::(PolicyResult::VT_MINIMUM_READER_VERSION, Some(1)) + .unwrap() + } + } + #[inline] + pub fn outcome(&self) -> PolicyOutcome { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab + .get::(PolicyResult::VT_OUTCOME, Some(PolicyOutcome::Unknown)) + .unwrap() + } + } + #[inline] + pub fn details( + &self, + ) -> Option<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { + self._tab.get::<::flatbuffers::ForwardsUOffset< + ::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>, + >>(PolicyResult::VT_DETAILS, None) + } + } +} + +impl ::flatbuffers::Verifiable for PolicyResult<'_> { + #[inline] + fn run_verifier( + v: &mut ::flatbuffers::Verifier, + pos: usize, + ) -> Result<(), ::flatbuffers::InvalidFlatbuffer> { + v.visit_table(pos)? + .visit_field::("version", Self::VT_VERSION, false)? + .visit_field::( + "minimum_reader_version", + Self::VT_MINIMUM_READER_VERSION, + false, + )? + .visit_field::("outcome", Self::VT_OUTCOME, false)? + .visit_field::<::flatbuffers::ForwardsUOffset< + ::flatbuffers::Vector<'_, ::flatbuffers::ForwardsUOffset>, + >>("details", Self::VT_DETAILS, false)? + .finish(); + Ok(()) + } +} +pub struct PolicyResultArgs<'a> { + pub version: u32, + pub minimum_reader_version: u32, + pub outcome: PolicyOutcome, + pub details: Option< + ::flatbuffers::WIPOffset< + ::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>, + >, + >, +} +impl<'a> Default for PolicyResultArgs<'a> { + #[inline] + fn default() -> Self { + PolicyResultArgs { + version: 0, + minimum_reader_version: 1, + outcome: PolicyOutcome::Unknown, + details: None, + } + } +} + +pub struct PolicyResultBuilder<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> { + fbb_: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>, + start_: ::flatbuffers::WIPOffset<::flatbuffers::TableUnfinishedWIPOffset>, +} +impl<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> PolicyResultBuilder<'a, 'b, A> { + #[inline] + pub fn add_version(&mut self, version: u32) { + self.fbb_ + .push_slot::(PolicyResult::VT_VERSION, version, 0); + } + #[inline] + pub fn add_minimum_reader_version(&mut self, minimum_reader_version: u32) { + self.fbb_.push_slot::( + PolicyResult::VT_MINIMUM_READER_VERSION, + minimum_reader_version, + 1, + ); + } + #[inline] + pub fn add_outcome(&mut self, outcome: PolicyOutcome) { + self.fbb_.push_slot::( + PolicyResult::VT_OUTCOME, + outcome, + PolicyOutcome::Unknown, + ); + } + #[inline] + pub fn add_details( + &mut self, + details: ::flatbuffers::WIPOffset< + ::flatbuffers::Vector<'b, ::flatbuffers::ForwardsUOffset>>, + >, + ) { + self.fbb_ + .push_slot_always::<::flatbuffers::WIPOffset<_>>(PolicyResult::VT_DETAILS, details); + } + #[inline] + pub fn new( + _fbb: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>, + ) -> PolicyResultBuilder<'a, 'b, A> { + let start = _fbb.start_table(); + PolicyResultBuilder { + fbb_: _fbb, + start_: start, + } + } + #[inline] + pub fn finish(self) -> ::flatbuffers::WIPOffset> { + let o = self.fbb_.end_table(self.start_); + ::flatbuffers::WIPOffset::new(o.value()) + } +} + +impl ::core::fmt::Debug for PolicyResult<'_> { + fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + let mut ds = f.debug_struct("PolicyResult"); + ds.field("version", &self.version()); + ds.field("minimum_reader_version", &self.minimum_reader_version()); + ds.field("outcome", &self.outcome()); + ds.field("details", &self.details()); + ds.finish() + } +} +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq)] +pub struct PolicyResultT { + pub version: u32, + pub minimum_reader_version: u32, + pub outcome: PolicyOutcome, + pub details: Option>, +} +impl Default for PolicyResultT { + fn default() -> Self { + Self { + version: 0, + minimum_reader_version: 1, + outcome: PolicyOutcome::Unknown, + details: None, + } + } +} +impl PolicyResultT { + pub fn pack<'b, A: ::flatbuffers::Allocator + 'b>( + &self, + _fbb: &mut ::flatbuffers::FlatBufferBuilder<'b, A>, + ) -> ::flatbuffers::WIPOffset> { + let version = self.version; + let minimum_reader_version = self.minimum_reader_version; + let outcome = self.outcome; + let details = self.details.as_ref().map(|x| { + let w: alloc::vec::Vec<_> = x.iter().map(|t| t.pack(_fbb)).collect(); + _fbb.create_vector(&w) + }); + PolicyResult::create( + _fbb, + &PolicyResultArgs { + version, + minimum_reader_version, + outcome, + details, + }, + ) + } +} +#[inline] +/// Verifies that a buffer of bytes contains a `PolicyResult` +/// and returns it. +/// Note that verification is still experimental and may not +/// catch every error, or be maximally performant. For the +/// previous, unchecked, behavior use +/// `root_as_policy_result_unchecked`. +pub fn root_as_policy_result( + buf: &[u8], +) -> Result, ::flatbuffers::InvalidFlatbuffer> { + ::flatbuffers::root::(buf) +} +#[inline] +/// Verifies that a buffer of bytes contains a size prefixed +/// `PolicyResult` and returns it. +/// Note that verification is still experimental and may not +/// catch every error, or be maximally performant. For the +/// previous, unchecked, behavior use +/// `size_prefixed_root_as_policy_result_unchecked`. +pub fn size_prefixed_root_as_policy_result( + buf: &[u8], +) -> Result, ::flatbuffers::InvalidFlatbuffer> { + ::flatbuffers::size_prefixed_root::(buf) +} +#[inline] +/// Verifies, with the given options, that a buffer of bytes +/// contains a `PolicyResult` and returns it. +/// Note that verification is still experimental and may not +/// catch every error, or be maximally performant. For the +/// previous, unchecked, behavior use +/// `root_as_policy_result_unchecked`. +pub fn root_as_policy_result_with_opts<'b, 'o>( + opts: &'o ::flatbuffers::VerifierOptions, + buf: &'b [u8], +) -> Result, ::flatbuffers::InvalidFlatbuffer> { + ::flatbuffers::root_with_opts::>(opts, buf) +} +#[inline] +/// Verifies, with the given verifier options, that a buffer of +/// bytes contains a size prefixed `PolicyResult` and returns +/// it. Note that verification is still experimental and may not +/// catch every error, or be maximally performant. For the +/// previous, unchecked, behavior use +/// `root_as_policy_result_unchecked`. +pub fn size_prefixed_root_as_policy_result_with_opts<'b, 'o>( + opts: &'o ::flatbuffers::VerifierOptions, + buf: &'b [u8], +) -> Result, ::flatbuffers::InvalidFlatbuffer> { + ::flatbuffers::size_prefixed_root_with_opts::>(opts, buf) +} +#[inline] +/// Assumes, without verification, that a buffer of bytes contains a PolicyResult and returns it. +/// # Safety +/// Callers must trust the given bytes do indeed contain a valid `PolicyResult`. +pub unsafe fn root_as_policy_result_unchecked(buf: &[u8]) -> PolicyResult<'_> { + unsafe { ::flatbuffers::root_unchecked::(buf) } +} +#[inline] +/// Assumes, without verification, that a buffer of bytes contains a size prefixed PolicyResult and returns it. +/// # Safety +/// Callers must trust the given bytes do indeed contain a valid size prefixed `PolicyResult`. +pub unsafe fn size_prefixed_root_as_policy_result_unchecked(buf: &[u8]) -> PolicyResult<'_> { + unsafe { ::flatbuffers::size_prefixed_root_unchecked::(buf) } +} +pub const POLICY_RESULT_IDENTIFIER: &str = "PSEP"; + +#[inline] +pub fn policy_result_buffer_has_identifier(buf: &[u8]) -> bool { + ::flatbuffers::buffer_has_identifier(buf, POLICY_RESULT_IDENTIFIER, false) +} + +#[inline] +pub fn policy_result_size_prefixed_buffer_has_identifier(buf: &[u8]) -> bool { + ::flatbuffers::buffer_has_identifier(buf, POLICY_RESULT_IDENTIFIER, true) +} + +#[inline] +pub fn finish_policy_result_buffer<'a, 'b, A: ::flatbuffers::Allocator + 'a>( + fbb: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>, + root: ::flatbuffers::WIPOffset>, +) { + fbb.finish(root, Some(POLICY_RESULT_IDENTIFIER)); +} + +#[inline] +pub fn finish_size_prefixed_policy_result_buffer<'a, 'b, A: ::flatbuffers::Allocator + 'a>( + fbb: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>, + root: ::flatbuffers::WIPOffset>, +) { + fbb.finish_size_prefixed(root, Some(POLICY_RESULT_IDENTIFIER)); +} diff --git a/src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/policy_result_limit_generated.rs b/src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/policy_result_limit_generated.rs new file mode 100644 index 0000000000..e6b640a94c --- /dev/null +++ b/src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/policy_result_limit_generated.rs @@ -0,0 +1,102 @@ +// automatically generated by the FlatBuffers compiler, do not modify +// @generated +extern crate alloc; +use super::*; +#[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." +)] +pub const ENUM_MIN_POLICY_RESULT_LIMIT: u32 = 64; +#[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." +)] +pub const ENUM_MAX_POLICY_RESULT_LIMIT: u32 = 131072; +#[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." +)] +#[allow(non_camel_case_types)] +pub const ENUM_VALUES_POLICY_RESULT_LIMIT: [PolicyResultLimit; 3] = [ + PolicyResultLimit::MaxDetails, + PolicyResultLimit::MaxResourceChars, + PolicyResultLimit::MaxResultBytes, +]; + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +#[repr(transparent)] +pub struct PolicyResultLimit(pub u32); +#[allow(non_upper_case_globals)] +impl PolicyResultLimit { + pub const MaxDetails: Self = Self(64); + pub const MaxResourceChars: Self = Self(32768); + pub const MaxResultBytes: Self = Self(131072); + + pub const ENUM_MIN: u32 = 64; + pub const ENUM_MAX: u32 = 131072; + pub const ENUM_VALUES: &'static [Self] = &[ + Self::MaxDetails, + Self::MaxResourceChars, + Self::MaxResultBytes, + ]; + /// Returns the variant's name or "" if unknown. + pub fn variant_name(self) -> Option<&'static str> { + match self { + Self::MaxDetails => Some("MaxDetails"), + Self::MaxResourceChars => Some("MaxResourceChars"), + Self::MaxResultBytes => Some("MaxResultBytes"), + _ => None, + } + } +} +impl ::core::fmt::Debug for PolicyResultLimit { + fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result { + if let Some(name) = self.variant_name() { + f.write_str(name) + } else { + f.write_fmt(format_args!("", self.0)) + } + } +} +impl<'a> ::flatbuffers::Follow<'a> for PolicyResultLimit { + type Inner = Self; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + let b = unsafe { ::flatbuffers::read_scalar_at::(buf, loc) }; + Self(b) + } +} + +impl ::flatbuffers::Push for PolicyResultLimit { + type Output = PolicyResultLimit; + #[inline] + unsafe fn push(&self, dst: &mut [u8], _written_len: usize) { + unsafe { ::flatbuffers::emplace_scalar::(dst, self.0) }; + } +} + +impl ::flatbuffers::EndianScalar for PolicyResultLimit { + type Scalar = u32; + #[inline] + fn to_little_endian(self) -> u32 { + self.0.to_le() + } + #[inline] + #[allow(clippy::wrong_self_convention)] + fn from_little_endian(v: u32) -> Self { + let b = u32::from_le(v); + Self(b) + } +} + +impl<'a> ::flatbuffers::Verifiable for PolicyResultLimit { + #[inline] + fn run_verifier( + v: &mut ::flatbuffers::Verifier, + pos: usize, + ) -> Result<(), ::flatbuffers::InvalidFlatbuffer> { + u32::run_verifier(v, pos) + } +} + +impl ::flatbuffers::SimpleToVerifyInSlice for PolicyResultLimit {} diff --git a/src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/policy_result_version_generated.rs b/src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/policy_result_version_generated.rs new file mode 100644 index 0000000000..37aab2bc22 --- /dev/null +++ b/src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/policy_result_version_generated.rs @@ -0,0 +1,90 @@ +// automatically generated by the FlatBuffers compiler, do not modify +// @generated +extern crate alloc; +use super::*; +#[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." +)] +pub const ENUM_MIN_POLICY_RESULT_VERSION: u32 = 1; +#[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." +)] +pub const ENUM_MAX_POLICY_RESULT_VERSION: u32 = 1; +#[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." +)] +#[allow(non_camel_case_types)] +pub const ENUM_VALUES_POLICY_RESULT_VERSION: [PolicyResultVersion; 1] = [PolicyResultVersion::V1]; + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +#[repr(transparent)] +pub struct PolicyResultVersion(pub u32); +#[allow(non_upper_case_globals)] +impl PolicyResultVersion { + pub const V1: Self = Self(1); + + pub const ENUM_MIN: u32 = 1; + pub const ENUM_MAX: u32 = 1; + pub const ENUM_VALUES: &'static [Self] = &[Self::V1]; + /// Returns the variant's name or "" if unknown. + pub fn variant_name(self) -> Option<&'static str> { + match self { + Self::V1 => Some("V1"), + _ => None, + } + } +} +impl ::core::fmt::Debug for PolicyResultVersion { + fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result { + if let Some(name) = self.variant_name() { + f.write_str(name) + } else { + f.write_fmt(format_args!("", self.0)) + } + } +} +impl<'a> ::flatbuffers::Follow<'a> for PolicyResultVersion { + type Inner = Self; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + let b = unsafe { ::flatbuffers::read_scalar_at::(buf, loc) }; + Self(b) + } +} + +impl ::flatbuffers::Push for PolicyResultVersion { + type Output = PolicyResultVersion; + #[inline] + unsafe fn push(&self, dst: &mut [u8], _written_len: usize) { + unsafe { ::flatbuffers::emplace_scalar::(dst, self.0) }; + } +} + +impl ::flatbuffers::EndianScalar for PolicyResultVersion { + type Scalar = u32; + #[inline] + fn to_little_endian(self) -> u32 { + self.0.to_le() + } + #[inline] + #[allow(clippy::wrong_self_convention)] + fn from_little_endian(v: u32) -> Self { + let b = u32::from_le(v); + Self(b) + } +} + +impl<'a> ::flatbuffers::Verifiable for PolicyResultVersion { + #[inline] + fn run_verifier( + v: &mut ::flatbuffers::Verifier, + pos: usize, + ) -> Result<(), ::flatbuffers::InvalidFlatbuffer> { + u32::run_verifier(v, pos) + } +} + +impl ::flatbuffers::SimpleToVerifyInSlice for PolicyResultVersion {} diff --git a/src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/required_action_generated.rs b/src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/required_action_generated.rs new file mode 100644 index 0000000000..79b2ea07c9 --- /dev/null +++ b/src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/required_action_generated.rs @@ -0,0 +1,106 @@ +// automatically generated by the FlatBuffers compiler, do not modify +// @generated +extern crate alloc; +use super::*; +#[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." +)] +pub const ENUM_MIN_REQUIRED_ACTION: u32 = 0; +#[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." +)] +pub const ENUM_MAX_REQUIRED_ACTION: u32 = 3; +#[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." +)] +#[allow(non_camel_case_types)] +pub const ENUM_VALUES_REQUIRED_ACTION: [RequiredAction; 4] = [ + RequiredAction::None, + RequiredAction::RemoveCapability, + RequiredAction::RestrictFilesystemAccess, + RequiredAction::ReducePathCount, +]; + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +#[repr(transparent)] +pub struct RequiredAction(pub u32); +#[allow(non_upper_case_globals)] +impl RequiredAction { + pub const None: Self = Self(0); + pub const RemoveCapability: Self = Self(1); + pub const RestrictFilesystemAccess: Self = Self(2); + pub const ReducePathCount: Self = Self(3); + + pub const ENUM_MIN: u32 = 0; + pub const ENUM_MAX: u32 = 3; + pub const ENUM_VALUES: &'static [Self] = &[ + Self::None, + Self::RemoveCapability, + Self::RestrictFilesystemAccess, + Self::ReducePathCount, + ]; + /// Returns the variant's name or "" if unknown. + pub fn variant_name(self) -> Option<&'static str> { + match self { + Self::None => Some("None"), + Self::RemoveCapability => Some("RemoveCapability"), + Self::RestrictFilesystemAccess => Some("RestrictFilesystemAccess"), + Self::ReducePathCount => Some("ReducePathCount"), + _ => None, + } + } +} +impl ::core::fmt::Debug for RequiredAction { + fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result { + if let Some(name) = self.variant_name() { + f.write_str(name) + } else { + f.write_fmt(format_args!("", self.0)) + } + } +} +impl<'a> ::flatbuffers::Follow<'a> for RequiredAction { + type Inner = Self; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + let b = unsafe { ::flatbuffers::read_scalar_at::(buf, loc) }; + Self(b) + } +} + +impl ::flatbuffers::Push for RequiredAction { + type Output = RequiredAction; + #[inline] + unsafe fn push(&self, dst: &mut [u8], _written_len: usize) { + unsafe { ::flatbuffers::emplace_scalar::(dst, self.0) }; + } +} + +impl ::flatbuffers::EndianScalar for RequiredAction { + type Scalar = u32; + #[inline] + fn to_little_endian(self) -> u32 { + self.0.to_le() + } + #[inline] + #[allow(clippy::wrong_self_convention)] + fn from_little_endian(v: u32) -> Self { + let b = u32::from_le(v); + Self(b) + } +} + +impl<'a> ::flatbuffers::Verifiable for RequiredAction { + #[inline] + fn run_verifier( + v: &mut ::flatbuffers::Verifier, + pos: usize, + ) -> Result<(), ::flatbuffers::InvalidFlatbuffer> { + u32::run_verifier(v, pos) + } +} + +impl ::flatbuffers::SimpleToVerifyInSlice for RequiredAction {} diff --git a/src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/resource_kind_generated.rs b/src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/resource_kind_generated.rs new file mode 100644 index 0000000000..375a3ac37c --- /dev/null +++ b/src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/resource_kind_generated.rs @@ -0,0 +1,98 @@ +// automatically generated by the FlatBuffers compiler, do not modify +// @generated +extern crate alloc; +use super::*; +#[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." +)] +pub const ENUM_MIN_RESOURCE_KIND: u32 = 0; +#[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." +)] +pub const ENUM_MAX_RESOURCE_KIND: u32 = 2; +#[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." +)] +#[allow(non_camel_case_types)] +pub const ENUM_VALUES_RESOURCE_KIND: [ResourceKind; 3] = [ + ResourceKind::None, + ResourceKind::Path, + ResourceKind::Capability, +]; + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +#[repr(transparent)] +pub struct ResourceKind(pub u32); +#[allow(non_upper_case_globals)] +impl ResourceKind { + pub const None: Self = Self(0); + pub const Path: Self = Self(1); + pub const Capability: Self = Self(2); + + pub const ENUM_MIN: u32 = 0; + pub const ENUM_MAX: u32 = 2; + pub const ENUM_VALUES: &'static [Self] = &[Self::None, Self::Path, Self::Capability]; + /// Returns the variant's name or "" if unknown. + pub fn variant_name(self) -> Option<&'static str> { + match self { + Self::None => Some("None"), + Self::Path => Some("Path"), + Self::Capability => Some("Capability"), + _ => None, + } + } +} +impl ::core::fmt::Debug for ResourceKind { + fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result { + if let Some(name) = self.variant_name() { + f.write_str(name) + } else { + f.write_fmt(format_args!("", self.0)) + } + } +} +impl<'a> ::flatbuffers::Follow<'a> for ResourceKind { + type Inner = Self; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + let b = unsafe { ::flatbuffers::read_scalar_at::(buf, loc) }; + Self(b) + } +} + +impl ::flatbuffers::Push for ResourceKind { + type Output = ResourceKind; + #[inline] + unsafe fn push(&self, dst: &mut [u8], _written_len: usize) { + unsafe { ::flatbuffers::emplace_scalar::(dst, self.0) }; + } +} + +impl ::flatbuffers::EndianScalar for ResourceKind { + type Scalar = u32; + #[inline] + fn to_little_endian(self) -> u32 { + self.0.to_le() + } + #[inline] + #[allow(clippy::wrong_self_convention)] + fn from_little_endian(v: u32) -> Self { + let b = u32::from_le(v); + Self(b) + } +} + +impl<'a> ::flatbuffers::Verifiable for ResourceKind { + #[inline] + fn run_verifier( + v: &mut ::flatbuffers::Verifier, + pos: usize, + ) -> Result<(), ::flatbuffers::InvalidFlatbuffer> { + u32::run_verifier(v, pos) + } +} + +impl ::flatbuffers::SimpleToVerifyInSlice for ResourceKind {} diff --git a/src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/value_kind_generated.rs b/src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/value_kind_generated.rs new file mode 100644 index 0000000000..18d739def9 --- /dev/null +++ b/src/mxc-sdk/src/core/process_security_environment_policy_result/experimental_process_security_environment_policy_result/value_kind_generated.rs @@ -0,0 +1,95 @@ +// automatically generated by the FlatBuffers compiler, do not modify +// @generated +extern crate alloc; +use super::*; +#[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." +)] +pub const ENUM_MIN_VALUE_KIND: u32 = 0; +#[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." +)] +pub const ENUM_MAX_VALUE_KIND: u32 = 2; +#[deprecated( + since = "2.0.0", + note = "Use associated constants instead. This will no longer be generated in 2021." +)] +#[allow(non_camel_case_types)] +pub const ENUM_VALUES_VALUE_KIND: [ValueKind; 3] = + [ValueKind::None, ValueKind::Access, ValueKind::PathCount]; + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +#[repr(transparent)] +pub struct ValueKind(pub u32); +#[allow(non_upper_case_globals)] +impl ValueKind { + pub const None: Self = Self(0); + pub const Access: Self = Self(1); + pub const PathCount: Self = Self(2); + + pub const ENUM_MIN: u32 = 0; + pub const ENUM_MAX: u32 = 2; + pub const ENUM_VALUES: &'static [Self] = &[Self::None, Self::Access, Self::PathCount]; + /// Returns the variant's name or "" if unknown. + pub fn variant_name(self) -> Option<&'static str> { + match self { + Self::None => Some("None"), + Self::Access => Some("Access"), + Self::PathCount => Some("PathCount"), + _ => None, + } + } +} +impl ::core::fmt::Debug for ValueKind { + fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result { + if let Some(name) = self.variant_name() { + f.write_str(name) + } else { + f.write_fmt(format_args!("", self.0)) + } + } +} +impl<'a> ::flatbuffers::Follow<'a> for ValueKind { + type Inner = Self; + #[inline] + unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { + let b = unsafe { ::flatbuffers::read_scalar_at::(buf, loc) }; + Self(b) + } +} + +impl ::flatbuffers::Push for ValueKind { + type Output = ValueKind; + #[inline] + unsafe fn push(&self, dst: &mut [u8], _written_len: usize) { + unsafe { ::flatbuffers::emplace_scalar::(dst, self.0) }; + } +} + +impl ::flatbuffers::EndianScalar for ValueKind { + type Scalar = u32; + #[inline] + fn to_little_endian(self) -> u32 { + self.0.to_le() + } + #[inline] + #[allow(clippy::wrong_self_convention)] + fn from_little_endian(v: u32) -> Self { + let b = u32::from_le(v); + Self(b) + } +} + +impl<'a> ::flatbuffers::Verifiable for ValueKind { + #[inline] + fn run_verifier( + v: &mut ::flatbuffers::Verifier, + pos: usize, + ) -> Result<(), ::flatbuffers::InvalidFlatbuffer> { + u32::run_verifier(v, pos) + } +} + +impl ::flatbuffers::SimpleToVerifyInSlice for ValueKind {} diff --git a/src/mxc-sdk/src/core/process_security_environment_policy_result/mod.rs b/src/mxc-sdk/src/core/process_security_environment_policy_result/mod.rs new file mode 100644 index 0000000000..cab71c8139 --- /dev/null +++ b/src/mxc-sdk/src/core/process_security_environment_policy_result/mod.rs @@ -0,0 +1,30 @@ +// Automatically generated by the Flatbuffers compiler. Do not modify. +// @generated +#![allow(unused_imports, non_snake_case, non_camel_case_types, clippy::all)] +pub mod experimental_process_security_environment_policy_result { + use super::*; + mod policy_result_version_generated; + pub use self::policy_result_version_generated::*; + mod policy_result_limit_generated; + pub use self::policy_result_limit_generated::*; + mod policy_outcome_generated; + pub use self::policy_outcome_generated::*; + mod failure_class_generated; + pub use self::failure_class_generated::*; + mod failure_reason_generated; + pub use self::failure_reason_generated::*; + mod required_action_generated; + pub use self::required_action_generated::*; + mod resource_kind_generated; + pub use self::resource_kind_generated::*; + mod value_kind_generated; + pub use self::value_kind_generated::*; + mod policy_access_generated; + pub use self::policy_access_generated::*; + mod policy_result_flags_generated; + pub use self::policy_result_flags_generated::*; + mod policy_detail_generated; + pub use self::policy_detail_generated::*; + mod policy_result_generated; + pub use self::policy_result_generated::*; +} // experimental_process_security_environment_policy_result diff --git a/src/mxc-sdk/src/core/process_security_environment_spec/README.md b/src/mxc-sdk/src/core/process_security_environment_spec/README.md index eab72de8a7..933da7d6d5 100644 --- a/src/mxc-sdk/src/core/process_security_environment_spec/README.md +++ b/src/mxc-sdk/src/core/process_security_environment_spec/README.md @@ -21,6 +21,17 @@ exact `flatc` version used — is recorded in That file is the single source of truth consumed by both the regeneration script and the CI drift gate. +The same provenance file also pins the separate experimental policy-result +schema, `ExperimentalProcessSecurityEnvironmentPolicyResult.fbs` (`PSEP`). +Its bindings are generated into the private +`src/mxc-sdk/src/core/process_security_environment_policy_result/` module. +PSEC describes the request passed to Windows; PSEP describes returned +creation-policy refusal details. Neither schema defines the public MXC request +JSON contract. +PSEP also defines the canonical result versions, limits and flags used by its +decoder. Refresh that schema and regenerate rather than copying its numeric +definitions from a native header. + ## Regenerating Install the **exact** `flatc` version pinned in the provenance file @@ -31,8 +42,9 @@ pwsh -File scripts/versioning/regenerate-psec.ps1 ``` Pass `-Flatc ` when `flatc.exe` is not on `PATH`. The script validates the -schema SHA-256 against the provenance file and refuses any `flatc` version other -than the pinned one, so output is byte-reproducible. Older `flatc` releases emit +schema hashes against the provenance file, regenerates both modules, and +refuses any `flatc` version other than the pinned one, so output is +byte-reproducible. Older `flatc` releases emit elided lifetimes that trip the `mismatched_lifetime_syntaxes` lint (Rust 1.89+); `25.12.19` is the first release with the upstream fix (flatbuffers PR #8709). diff --git a/src/mxc-sdk/src/dev.rs b/src/mxc-sdk/src/dev.rs index c39859e8e7..3623b0a873 100644 --- a/src/mxc-sdk/src/dev.rs +++ b/src/mxc-sdk/src/dev.rs @@ -14,6 +14,12 @@ pub use state_aware::{ validate_start_json, validate_stop_json, }; +pub use crate::mxc_common::policy_failures::{ + PolicyFailureDetail, PolicyFailureResult, PolicyFailures, +}; +pub use crate::mxc_common::policy_preparation::{ + PolicyChange, PolicyChangeOperation, PreparedPolicyRequest, +}; use crate::sandbox::MxcPtySize; /// Invocation controls for an exact-JSON operation. @@ -23,6 +29,122 @@ pub struct JsonOptions { pub experimental: bool, } +/// Controls for preparing a candidate request without creating a container. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[non_exhaustive] +pub struct PolicyPreparationOptions { + /// Authorize experimental backends independently of the JSON contract version. + pub experimental: bool, +} + +/// Prepare a restrictive candidate, without creating an environment or workload. +/// +/// Supported only for native Windows ProcessContainer create-and-run requests. +/// Inspect and accept, edit or reject the result before submitting the chosen +/// request through ordinary strict [`run_json`] or [`spawn_json`]. +/// Unsupported or unsafe observations fail explicitly. Request input is limited +/// to 8 MiB, the complete result to 32 MiB, and its report to 4,096 changes. +pub fn prepare_policy_json( + json: &str, + failures: &PolicyFailures, + options: PolicyPreparationOptions, +) -> Result { + crate::mxc_engine::prepare_policy_json(json, failures, options.experimental) +} + +impl PreparedPolicyRequest { + /// Serialize the owned candidate and its structured changes for caller storage. + pub fn to_json(&self) -> Result { + self.validate().map_err(|message| { + crate::mxc_engine::Error::new(crate::mxc_engine::ErrorCode::MalformedRequest, message) + })?; + serde_json::to_string(self).map_err(|_| { + crate::mxc_engine::Error::new( + crate::mxc_engine::ErrorCode::BackendError, + "cannot serialize policy preparation", + ) + }) + } + + /// Restore candidate data; this is not policy approval or creation validation. + pub fn from_json(json: &str) -> Result { + Self::parse(json).map_err(|message| { + crate::mxc_engine::Error::new(crate::mxc_engine::ErrorCode::MalformedRequest, message) + }) + } +} + +impl PolicyFailures { + /// Merge owned observation batches without changing either input or truncating data. + pub fn merged_with(&self, newer: &Self) -> Result { + use crate::mxc_engine::{Error, ErrorCode}; + self.validate_storage() + .map_err(|message| Error::new(ErrorCode::MalformedRequest, message))?; + newer + .validate_storage() + .map_err(|message| Error::new(ErrorCode::MalformedRequest, message))?; + let mut merged = Self::default(); + for result in self.results.iter().chain(&newer.results) { + if !merged.results.contains(result) { + merged.results.push(result.clone()); + } + } + merged + .validate_storage() + .map_err(|message| Error::new(ErrorCode::MalformedRequest, message))?; + Ok(merged) + } + + /// Serialize policy failures for application-owned storage. + /// + /// A terminal over-limit native result can be stored but cannot be + /// prepared unchanged. Preparation validates the stricter input limits. + pub fn to_json(&self) -> Result { + use crate::mxc_engine::{Error, ErrorCode}; + self.validate_storage() + .map_err(|message| Error::new(ErrorCode::MalformedRequest, message))?; + serde_json::to_string(self).map_err(|error| { + Error::new( + ErrorCode::BackendError, + format!("cannot serialize policy failures: {error}"), + ) + }) + } + + /// Restore policy failures without interpreting them as policy approval. + pub fn from_json(json: &str) -> Result { + Self::parse_stored(json).map_err(|message| { + crate::mxc_engine::Error::new(crate::mxc_engine::ErrorCode::MalformedRequest, message) + }) + } + + /// Copy the observations from one SDK error. + /// + /// The presence of observations alone does not mean another attempt is safe. + /// Check the current error, terminal policy-failure errors, and application limits. + pub fn from_error( + error: &crate::mxc_engine::Error, + ) -> Result, crate::mxc_engine::Error> { + use crate::mxc_engine::{Error, ErrorCode}; + let Some(value) = error + .details + .as_deref() + .and_then(|details| details.get("policyFailures")) + else { + return Ok(None); + }; + let malformed = || { + Error::new( + ErrorCode::BackendError, + "native policy failures are malformed", + ) + }; + let failures: Self = serde_json::from_value(value.clone()).map_err(|_| malformed())?; + failures.validate_storage().map_err(|_| malformed())?; + Ok(Some(failures)) + } +} + /// Invocation controls for an exact-JSON terminal operation. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct PtyJsonOptions { diff --git a/src/mxc-sdk/src/lib.rs b/src/mxc-sdk/src/lib.rs index 3841b6b989..f32b334dea 100644 --- a/src/mxc-sdk/src/lib.rs +++ b/src/mxc-sdk/src/lib.rs @@ -394,6 +394,11 @@ pub mod v1 { pub mod __ffi { use super::*; use crate::sandbox::WaitResult; + /// Decode bounded preparation input, rather than a terminal stored report. + pub fn decode_policy_failures_json(json: &str) -> Result { + v1::dev::PolicyFailures::parse(json) + .map_err(|message| Error::new(ErrorCode::MalformedRequest, message)) + } /// Spawn a raw exact-version JSON container request as a live process. /// diff --git a/src/mxc-sdk/tests/dev_compatibility.rs b/src/mxc-sdk/tests/dev_compatibility.rs new file mode 100644 index 0000000000..fa35fedad6 --- /dev/null +++ b/src/mxc-sdk/tests/dev_compatibility.rs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Source compatibility with the supported V1.Dev options and creation calls. + +use mxc_sdk::v1::{ + dev::{self, JsonOptions}, + Error, ExecutionResult, MxcProcess, +}; + +fn saved_options() -> Vec { + vec![ + JsonOptions { + experimental: false, + }, + JsonOptions { experimental: true }, + ] +} + +#[test] +fn supported_v1_dev_literals_storage_and_creation_signatures_compile() { + let run: fn(&str, JsonOptions) -> Result = dev::run_json; + let spawn: fn(&str, JsonOptions) -> Result = dev::spawn_json; + for options in saved_options() { + let error = run("{}", options).expect_err("an empty request must be rejected"); + assert_eq!(error.code, mxc_sdk::v1::ErrorCode::MalformedRequest); + let error = spawn("{}", options) + .err() + .expect("an empty request must be rejected"); + assert_eq!(error.code, mxc_sdk::v1::ErrorCode::MalformedRequest); + } +} diff --git a/src/mxc-sdk/tests/streaming_processcontainer.rs b/src/mxc-sdk/tests/streaming_processcontainer.rs index f55cd270d9..8773e79792 100644 --- a/src/mxc-sdk/tests/streaming_processcontainer.rs +++ b/src/mxc-sdk/tests/streaming_processcontainer.rs @@ -56,6 +56,151 @@ fn dev_json_processcontainer_capture_and_pipes() { } } +#[test] +#[ignore = "requires a PSEC-capable Windows host"] +fn dev_json_processcontainer_policy_feedback_restricts_current_request() { + use mxc_sdk::v1::dev::{ + self, JsonOptions, PolicyChangeOperation, PolicyFailureDetail as D, + PolicyFailureResult as R, PolicyFailures, PreparedPolicyRequest, + }; + + let directory = tempfile::tempdir().expect("policy preparation workspace"); + let restricted = directory.path().join("restricted"); + let unrelated = directory.path().join("unrelated"); + std::fs::create_dir(&restricted).unwrap(); + std::fs::create_dir(&unrelated).unwrap(); + let target = restricted.join("target.txt"); + let control = unrelated.join("control.txt"); + std::fs::write(&target, b"seed").unwrap(); + std::fs::write(&control, b"seed").unwrap(); + std::fs::write(restricted.join("relative.txt"), b"ORIGINAL_CWD").unwrap(); + std::fs::write(unrelated.join("relative.txt"), b"WRONG_CWD").unwrap(); + let resource: Vec = restricted.to_string_lossy().encode_utf16().collect(); + let failures = PolicyFailures::from_json( + &serde_json::json!({ + "formatVersion": 1, + "results": [{ + "version": 1, "minimumReaderVersion": 1, "outcome": R::OUTCOME_BLOCKED, + "details": [{ + "failureClass": D::CLASS_FILESYSTEM, + "failureReason": D::REASON_ACCESS_EXCEEDS_CEILING, + "requiredAction": D::ACTION_RESTRICT_FILESYSTEM_ACCESS, + "resourceKind": D::RESOURCE_KIND_PATH, "valueKind": D::VALUE_KIND_ACCESS, + "flags": D::FLAG_HAS_ACTIONABLE_DETAILS | D::FLAG_RESOURCE_COMPLETE, + "requestedValue": D::ACCESS_READ_WRITE.to_string(), + "requiredValue": D::ACCESS_READ_ONLY.to_string(), "resource": resource, + }], + }], + }) + .to_string(), + ) + .unwrap(); + let failures = PolicyFailures::default().merged_with(&failures).unwrap(); + let failures = PolicyFailures::from_json(&failures.to_json().unwrap()).unwrap(); + assert_eq!(failures.merged_with(&failures).unwrap(), failures); + let accepts = |prepared: &PreparedPolicyRequest| { + prepared.changes.len() == 3 + && prepared + .changes + .iter() + .all(|change| match change.path.as_str() { + "/filesystem/readwritePaths" => { + change.operation == PolicyChangeOperation::Replace + && change.before == serde_json::json!([restricted, unrelated]) + && change.after == serde_json::json!([unrelated]) + } + "/filesystem/readonlyPaths" => { + change.operation == PolicyChangeOperation::Add + && change.before.is_null() + && change.after == serde_json::json!([restricted]) + } + "/process/cwd" => { + change.operation == PolicyChangeOperation::Add + && change.before.is_null() + && change.after == serde_json::json!(restricted) + } + _ => false, + }) + }; + for version in ["0.9.0-alpha", "1.0.0", "1.1.0-alpha"] { + std::fs::write(&target, b"seed").unwrap(); + std::fs::write(&control, b"seed").unwrap(); + let request = serde_json::json!({ + "version": version, "containerId": "policy-preparation-public-test", + "containment": "processcontainer", + "process": { + "commandLine": format!( + "cmd.exe /d /c echo CHANGED>>\"{}\" & echo CONTROL>>\"{}\"", + target.display(), control.display(), + ), + "timeout": 30000, + }, + "filesystem": { "readwritePaths": [restricted, unrelated] }, + }) + .to_string(); + let options = JsonOptions { + experimental: false, + }; + let baseline = dev::run_json(&request, options).expect("strict writable control"); + assert_eq!(baseline.outcome, WaitResult::Exited(0)); + assert!(std::fs::read_to_string(&target) + .unwrap() + .contains("CHANGED")); + std::fs::write(&target, b"seed").unwrap(); + std::fs::write(&control, b"seed").unwrap(); + + // Observations are supplied, so the result does not depend on host policy. + let prepared = dev::prepare_policy_json(&request, &failures, Default::default()) + .expect("prepare a candidate without executing"); + assert_eq!(std::fs::read(&target).unwrap(), b"seed"); + assert_eq!(std::fs::read(&control).unwrap(), b"seed"); + let prepared = PreparedPolicyRequest::from_json(&prepared.to_json().unwrap()).unwrap(); + assert!( + accepts(&prepared), + "caller rejects changes outside this explicit policy" + ); + let candidate: serde_json::Value = serde_json::from_str(&prepared.request_json).unwrap(); + assert_eq!(candidate["version"], version); + assert_eq!(candidate["containerId"], "policy-preparation-public-test"); + let output = + dev::run_json(&prepared.request_json, options).expect("strict accepted creation"); + assert_eq!(output.outcome, WaitResult::Exited(0)); + assert_eq!(std::fs::read(&target).unwrap(), b"seed"); + assert!(std::fs::read_to_string(&control) + .unwrap() + .contains("CONTROL")); + assert!(output + .warnings + .iter() + .all(|warning| { !warning.contains("configuration updated due to IT policy") })); + + let mut changed_request: serde_json::Value = serde_json::from_str(&request).unwrap(); + changed_request["process"]["commandLine"] = format!( + "cmd.exe /d /c type relative.txt & echo FRESH>>\"{}\"", + control.display(), + ) + .into(); + let before_control = std::fs::read(&control).unwrap(); + let prepared = + dev::prepare_policy_json(&changed_request.to_string(), &failures, Default::default()) + .expect("prepare retained observations against the fresh request"); + assert_eq!(std::fs::read(&control).unwrap(), before_control); + assert!( + accepts(&prepared), + "caller checks the fresh report before accepting" + ); + let changed = + dev::run_json(&prepared.request_json, options).expect("strict fresh candidate"); + assert_eq!(changed.outcome, WaitResult::Exited(0)); + let stdout = String::from_utf8_lossy(&changed.stdout); + assert!(stdout.contains("ORIGINAL_CWD"), "{stdout}"); + assert!(!stdout.contains("WRONG_CWD"), "{stdout}"); + assert!(std::fs::read_to_string(&control).unwrap().contains("FRESH")); + assert_eq!(std::fs::read(&target).unwrap(), b"seed"); + println!("MXC_POLICY_PREPARATION_STRICT_CREATE_OK:{version}"); + } +} + #[test] #[ignore = "requires a PSEC-capable Windows host"] fn streaming_processcontainer_bidirectional_stdio() { From 310d62c5f16100a22e088edb46a256fa1e4e9ed7 Mon Sep 17 00:00:00 2001 From: Ross Nichols Date: Tue, 10 Nov 2026 08:18:00 -0800 Subject: [PATCH 2/5] Require native theory rows in CI results Require each native storage theory row to pass in scheduled validation, and cover missing native rows when the pure-storage rows pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ci-validation-infrastructure.md | 3 +++ scripts/ci/lib/TestSuiteResults.ps1 | 6 +++++ scripts/ci/run_backend_validation_tests.ps1 | 3 +++ .../test-policy-preparation-test-results.ps1 | 27 ++++++++++++++----- 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/docs/development/build-and-test/ci-validation-infrastructure.md b/docs/development/build-and-test/ci-validation-infrastructure.md index 08b5f2d9e3..18ee3f11c0 100644 --- a/docs/development/build-and-test/ci-validation-infrastructure.md +++ b/docs/development/build-and-test/ci-validation-infrastructure.md @@ -323,6 +323,9 @@ launching and lossless storage of native results. Their native-success rows require Windows and this host opt-in; pure storage rows run without it. Bundle discovery requires the native preparation fact and both native storage theory rows, and scheduled validation rejects skipped tests in either class. +Scheduled validation also requires the passing `nativePreparation: True` +result row for each storage theory; its pure-storage `False` row cannot satisfy +the native execution gate. The tool-discovery case uses a private fixture alongside the packaged Node tests rather than under the host temporary directory. It uses a fake `pwsh.exe`, applies the discovered policy through public `run`, and checks that diff --git a/scripts/ci/lib/TestSuiteResults.ps1 b/scripts/ci/lib/TestSuiteResults.ps1 index 90736d858c..62a24aac4c 100644 --- a/scripts/ci/lib/TestSuiteResults.ps1 +++ b/scripts/ci/lib/TestSuiteResults.ps1 @@ -58,6 +58,7 @@ function Get-XunitSuiteFailure { [Parameter(Mandatory)][string]$ResultXml, [Parameter(Mandatory)][string[]]$Classes, [string[]]$RequiredMethods = @(), + [string[]]$RequiredTestNames = @(), [string[]]$RequiredOutputText = @(), [switch]$RejectSkipped ) @@ -79,6 +80,11 @@ function Get-XunitSuiteFailure { $failures += "'$method' did not pass" } } + foreach ($name in $RequiredTestNames) { + if (@($selected | Where-Object { $_.name -eq $name -and $_.result -eq 'Pass' }).Count -eq 0) { + $failures += "'$name' did not pass" + } + } # Pins a specific theory row: its passing output must contain each marker. foreach ($text in $RequiredOutputText) { $found = @($selected | Where-Object { diff --git a/scripts/ci/run_backend_validation_tests.ps1 b/scripts/ci/run_backend_validation_tests.ps1 index 0d14f09bed..f6ff77fb4e 100644 --- a/scripts/ci/run_backend_validation_tests.ps1 +++ b/scripts/ci/run_backend_validation_tests.ps1 @@ -221,6 +221,9 @@ function Invoke-ProcessContainerStreamingTests { 'DevJson_CapturesOutputAndStreamsInput', 'PolicyPreparationReturnsOwnedNativeRequestsWithoutLaunching', 'PolicyPreparationStoragePreservesLargeTextWithoutHtmlExpansion', 'PolicyPreparationStoragePreservesDuplicateCommentMembers') ` + -RequiredTestNames @( + 'Microsoft.Mxc.Sdk.Tests.DevEntryPointTests.PolicyPreparationStoragePreservesLargeTextWithoutHtmlExpansion(nativePreparation: True)', + 'Microsoft.Mxc.Sdk.Tests.DevEntryPointTests.PolicyPreparationStoragePreservesDuplicateCommentMembers(nativePreparation: True)') ` -RequiredOutputText @('MXC_PREPARED_FILESYSTEM_BASELINE_RW_OK', 'MXC_PREPARED_FILESYSTEM_ACCEPTED:', 'MXC_PREPARED_FILESYSTEM_STRICT_RUN_SPAWN_OK:1.1.0-alpha:seed=unchanged:permitted=written') diff --git a/scripts/ci/test-policy-preparation-test-results.ps1 b/scripts/ci/test-policy-preparation-test-results.ps1 index 47f3a750ad..6e483e93fb 100644 --- a/scripts/ci/test-policy-preparation-test-results.ps1 +++ b/scripts/ci/test-policy-preparation-test-results.ps1 @@ -49,27 +49,40 @@ try { $methods = @('PolicyPreparationReturnsOwnedNativeRequestsWithoutLaunching', 'PolicyPreparationStoragePreservesLargeTextWithoutHtmlExpansion', 'PolicyPreparationStoragePreservesDuplicateCommentMembers') - foreach ($case in 'complete', 'missing-class', 'missing-method', 'skipped-native') { + $requiredNativeRows = @( + 'Ns.DevEntryPointTests.PolicyPreparationStoragePreservesLargeTextWithoutHtmlExpansion(nativePreparation: True)', + 'Ns.DevEntryPointTests.PolicyPreparationStoragePreservesDuplicateCommentMembers(nativePreparation: True)' + ) + foreach ($case in 'complete', 'missing-class', 'missing-method', 'skipped-native', + 'missing-native-large', 'missing-native-duplicate') { $rows = @("") if ($case -ne 'missing-class') { if ($case -ne 'missing-method') { $rows += "" } - foreach ($method in $methods[1..2]) { - $result = if ($case -eq 'skipped-native') { 'Skip' } else { 'Pass' } - $rows += "" - $rows += "" + for ($index = 1; $index -le 2; $index++) { + $method = $methods[$index] + $rowName = $requiredNativeRows[$index - 1] + $missingNativeRow = ($index -eq 1 -and $case -eq 'missing-native-large') -or + ($index -eq 2 -and $case -eq 'missing-native-duplicate') + if (-not $missingNativeRow) { + $result = if ($case -eq 'skipped-native') { 'Skip' } else { 'Pass' } + $rows += "" + } + $pureStorageName = $rowName -replace 'nativePreparation: True', 'nativePreparation: False' + $rows += "" } } Set-Content -LiteralPath $xunit -Value ( '' + ($rows -join '') + '') $failure = Get-XunitSuiteFailure -ResultXml $xunit -Classes 'MxcProcessTests', 'DevEntryPointTests' ` - -RejectSkipped -RequiredMethods (@('DevJson_CapturesOutputAndStreamsInput') + $methods) + -RejectSkipped -RequiredMethods (@('DevJson_CapturesOutputAndStreamsInput') + $methods) ` + -RequiredTestNames $requiredNativeRows if (($case -eq 'complete') -ne [string]::IsNullOrEmpty($failure)) { throw "Unexpected .NET native preparation gate for ${case}: $failure" } } - Write-Host 'Policy preparation result gates passed (14 cases).' + Write-Host 'Policy preparation result gates passed (16 cases).' } finally { Remove-Item -LiteralPath $directory -Recurse -Force From 314e4f320f4799e541e69e8b9e8d20d6ab761dd3 Mon Sep 17 00:00:00 2001 From: Ross Nichols Date: Tue, 10 Nov 2026 09:15:44 -0800 Subject: [PATCH 3/5] Fix policy preparation CI portability Compile the request projection helper only on Windows. Keep injected policy tests independent of native PSEC attribute support and use canonical fixture paths when testing literal filesystem restrictions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../policy_preparation_backend_tests.rs | 8 ++++- .../process_container/common/secenv.rs | 36 +++++++++++-------- src/mxc-sdk/src/core/mxc_common/splice.rs | 1 + 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/src/mxc-sdk/src/backends/process_container/common/policy_preparation_backend_tests.rs b/src/mxc-sdk/src/backends/process_container/common/policy_preparation_backend_tests.rs index 3d63230fc7..3975534998 100644 --- a/src/mxc-sdk/src/backends/process_container/common/policy_preparation_backend_tests.rs +++ b/src/mxc-sdk/src/backends/process_container/common/policy_preparation_backend_tests.rs @@ -2,6 +2,7 @@ // Licensed under the MIT License. use super::*; +use crate::mxc_common::filesystem_canonical::{canonicalize_path, PathCanonical}; use crate::mxc_common::logger::{Logger, Mode}; use crate::mxc_common::sandbox_process::{SandboxBackend, StdioMode}; use crate::process_container_common::base_container_runner::BaseContainerRunner; @@ -65,7 +66,12 @@ fn policy_preparation_preserves_contract_metadata_presence_and_inferred_cwd_in_s api.version_support = Some(version); let _scope = ApiScope(INJECTED_API.with(|slot| slot.replace(Some(api)))); let directory = tempfile::tempdir().unwrap(); - let root = directory.path(); + // TEMP may contain an 8.3 alias; this scenario requires literal grants. + let PathCanonical::Canonical(root) = canonicalize_path(directory.path().to_str().unwrap()) + else { + panic!("resolve the fixture's temporary directory"); + }; + let root = std::path::Path::new(&root); let work = root.join("work"); let unrelated = root.join("unrelated"); let denied = root.join("denied"); diff --git a/src/mxc-sdk/src/backends/process_container/common/secenv.rs b/src/mxc-sdk/src/backends/process_container/common/secenv.rs index f5121b6d38..1c4c02c644 100644 --- a/src/mxc-sdk/src/backends/process_container/common/secenv.rs +++ b/src/mxc-sdk/src/backends/process_container/common/secenv.rs @@ -344,21 +344,27 @@ impl SecurityEnvironmentStartupInfo { })?; let environment_value = Box::new(environment); - // SAFETY: the attribute list is initialized, and the boxed HANDLE has - // a stable address that outlives every use of the list. - if unsafe { - UpdateProcThreadAttribute( - attribute_list, - 0, - PROC_THREAD_ATTRIBUTE_SECURITY_ENVIRONMENT, - Some((&raw const *environment_value).cast()), - std::mem::size_of::(), - None, - None, - ) - } - .is_err() - { + let update_environment = || { + #[cfg(test)] + if policy_diagnostics_tests::injected_api().is_some() { + // The injected launcher consumes the fake PSEC handle, not Windows. + return Ok(()); + } + // SAFETY: the attribute list is initialized, and the boxed HANDLE has + // a stable address that outlives every use of the list. + unsafe { + UpdateProcThreadAttribute( + attribute_list, + 0, + PROC_THREAD_ATTRIBUTE_SECURITY_ENVIRONMENT, + Some((&raw const *environment_value).cast()), + std::mem::size_of::(), + None, + None, + ) + } + }; + if update_environment().is_err() { let code = last_error(); // SAFETY: balances the successful initialization above. unsafe { DeleteProcThreadAttributeList(attribute_list) }; diff --git a/src/mxc-sdk/src/core/mxc_common/splice.rs b/src/mxc-sdk/src/core/mxc_common/splice.rs index be26dba6e2..2f393b0392 100644 --- a/src/mxc-sdk/src/core/mxc_common/splice.rs +++ b/src/mxc-sdk/src/core/mxc_common/splice.rs @@ -62,6 +62,7 @@ impl<'de> Deserialize<'de> for RawObject<'de> { /// Serializes the `updated` object, copying the source text of each top-level /// member of `source` whose value is unchanged. Re-serializing those values /// would round numbers and collapse duplicate keys in caller annotations. +#[cfg(windows)] pub(crate) fn serialize_preserving_unchanged_members( source: &str, original: &serde_json::Map, From a62c2ebbbb158d1225635c719196ea20edea53eb Mon Sep 17 00:00:00 2001 From: Ross Nichols Date: Tue, 10 Nov 2026 10:24:06 -0800 Subject: [PATCH 4/5] Fix policy feedback review findings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DevEntryPointTests.cs | 36 +++++++++++ .../V1/Dev/PreparedPolicyRequest.cs | 2 + sdk/node/src/v1/errors.ts | 7 ++- sdk/node/tests/unit/errors.test.ts | 14 +++++ .../process_container/common/secenv_policy.rs | 63 ++++++++++++------- .../tests/streaming_processcontainer.rs | 12 +++- 6 files changed, 109 insertions(+), 25 deletions(-) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/DevEntryPointTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/DevEntryPointTests.cs index ca1984c5a2..b76bc1a18b 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/DevEntryPointTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/DevEntryPointTests.cs @@ -623,6 +623,42 @@ public void PreparedPolicyStorageRetainsRequiredNullValuesAndOwnsJsonElements() Assert.Equal(stored, prepared.ToJson()); } + [Fact] + public void PreparedPolicyStorageRejectsDuplicateChangeValuesButPreservesRequestAnnotations() + { + using var duplicate = JsonDocument.Parse("""{"nested":{"entry":1,"entry":2}}"""); + using var nullValue = JsonDocument.Parse("null"); + Assert.Throws(() => new Dev.PreparedPolicyRequest + { + RequestJson = "{}", + Changes = + [ + new() + { + Operation = Dev.PolicyChangeOperation.Replace, Path = "/value", + Before = duplicate.RootElement, After = nullValue.RootElement, + }, + ], + }.ToJson()); + Assert.Throws(() => new Dev.PreparedPolicyRequest + { + RequestJson = "{}", + Changes = + [ + new() + { + Operation = Dev.PolicyChangeOperation.Replace, Path = "/value", + Before = nullValue.RootElement, After = duplicate.RootElement, + }, + ], + }.ToJson()); + + const string requestJson = + """{"version":"1.1.0-alpha","_comment":"first","_comment":"second"}"""; + var stored = new Dev.PreparedPolicyRequest { RequestJson = requestJson }.ToJson(); + Assert.Equal(requestJson, Dev.PreparedPolicyRequest.FromJson(stored).RequestJson); + } + [Fact] public void PreparedPolicyStorageRejectsMalformedShapesPointersAndOperationsWithoutInputLeaks() { diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/Dev/PreparedPolicyRequest.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/Dev/PreparedPolicyRequest.cs index 64d833dc28..6d074ccd6c 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/Dev/PreparedPolicyRequest.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/Dev/PreparedPolicyRequest.cs @@ -156,6 +156,8 @@ private void ValidateStorage() throw new JsonException("prepared policy request has a null change"); } change.Validate(); + MxcJson.RejectDuplicateProperties(change.Before); + MxcJson.RejectDuplicateProperties(change.After); } } } diff --git a/sdk/node/src/v1/errors.ts b/sdk/node/src/v1/errors.ts index 728d3cb5cc..414ec541ab 100644 --- a/sdk/node/src/v1/errors.ts +++ b/sdk/node/src/v1/errors.ts @@ -144,8 +144,11 @@ Object.defineProperty(MxcError.prototype, inspect.custom, { value(this: MxcError): string { const context = [this.code, this.operation, this.nativeCode] .filter((value) => value !== undefined).join(' '); - const header = `${this.name}: ${this.message} [${context}]`; - const frames = this.stack?.split('\n').slice(1).join('\n'); + const remediation = + this.remediation === undefined ? '' : `\nRemediation: ${this.remediation}`; + const header = `${this.name}: ${this.message} [${context}]${remediation}`; + const messageLines = this.message.split('\n').length; + const frames = this.stack?.split('\n').slice(messageLines).join('\n'); return frames ? `${header}\n${frames}` : header; }, }); diff --git a/sdk/node/tests/unit/errors.test.ts b/sdk/node/tests/unit/errors.test.ts index 645d13e9d8..e2a383a760 100644 --- a/sdk/node/tests/unit/errors.test.ts +++ b/sdk/node/tests/unit/errors.test.ts @@ -3,6 +3,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert'; +import { inspect } from 'node:util'; import { ErrorCode, MxcError, mxcErrorFromCode, mxcErrorFromEnvelope } from '../../src/v1/errors.js'; const codes: ErrorCode[] = [ @@ -118,6 +119,19 @@ describe('MxcError structured fields', () => { assert.strictEqual(err.details, undefined); }); + it('shows remediation without duplicating multiline messages or exposing details', () => { + const err = new MxcError({ + code: 'backend_error', + message: 'first message line\nsecond message line', + remediation: 'Run the policy preparation step.', + details: { resource: 'PRIVATE_POLICY_RESOURCE' }, + }); + const output = inspect(err, { depth: null }); + assert.ok(output.includes('Remediation: Run the policy preparation step.')); + assert.strictEqual(output.split('second message line').length - 1, 1); + assert.strictEqual(output.includes('PRIVATE_POLICY_RESOURCE'), false); + }); + // The overload discriminates on `typeof === 'string'`, so a nullish // argument takes the object branch. Without a guard it fails inside // `super()` with a TypeError naming `message`, which tells the reader diff --git a/src/mxc-sdk/src/backends/process_container/common/secenv_policy.rs b/src/mxc-sdk/src/backends/process_container/common/secenv_policy.rs index 805470f3e3..d48f841475 100644 --- a/src/mxc-sdk/src/backends/process_container/common/secenv_policy.rs +++ b/src/mxc-sdk/src/backends/process_container/common/secenv_policy.rs @@ -79,36 +79,52 @@ impl PolicyResult { pub(crate) fn restriction_hints(&self) -> Option> { let details = self.details.as_deref().unwrap_or_default(); - if self.outcome.0 != 1 || details.is_empty() { + if self.outcome != layout::PolicyOutcome::Blocked || details.is_empty() { return None; } details .iter() .map(|detail| { - if detail.flags & ACTIONABLE == 0 || detail.flags & !KNOWN_FLAGS != 0 { + if detail.flags & ACTIONABLE != ACTIONABLE + || detail.flags & !KNOWN_FLAGS != layout::PolicyResultFlags::None.0 + { return None; } match ( - detail.failure_class.0, - detail.failure_reason.0, - detail.required_action.0, - detail.resource_kind.0, - detail.value_kind.0, + detail.failure_class, + detail.failure_reason, + detail.required_action, + detail.resource_kind, + detail.value_kind, ) { - (1..=3, 1, 1, 2, 0) - if detail.flags == ACTIONABLE | RESOURCE_COMPLETE - && detail.requested_value == 0 - && detail.required_value == 0 => + ( + layout::FailureClass::Network + | layout::FailureClass::Filesystem + | layout::FailureClass::EnforcementMode, + layout::FailureReason::CapabilityNotAllowed, + layout::RequiredAction::RemoveCapability, + layout::ResourceKind::Capability, + layout::ValueKind::None, + ) if detail.flags == ACTIONABLE | RESOURCE_COMPLETE + && detail.requested_value == layout::PolicyAccess::None.0 + && detail.required_value == layout::PolicyAccess::None.0 => { Some(RestrictionHint::RemoveCapability( detail.resource_text().ok()??, )) } - (2, 2 | 3, 2, 1, 1) - if detail.flags == ACTIONABLE | RESOURCE_COMPLETE - && detail.requested_value <= 2 - && detail.required_value < detail.requested_value - && (detail.failure_reason.0 != 3 || detail.required_value == 0) => + ( + layout::FailureClass::Filesystem, + layout::FailureReason::AccessExceedsCeiling + | layout::FailureReason::DenyRequired, + layout::RequiredAction::RestrictFilesystemAccess, + layout::ResourceKind::Path, + layout::ValueKind::Access, + ) if detail.flags == ACTIONABLE | RESOURCE_COMPLETE + && detail.requested_value <= layout::PolicyAccess::ReadWrite.0 + && detail.required_value < detail.requested_value + && (detail.failure_reason != layout::FailureReason::DenyRequired + || detail.required_value == layout::PolicyAccess::None.0) => { Some(RestrictionHint::RestrictPath { path: detail.resource_text().ok()??, @@ -116,10 +132,15 @@ impl PolicyResult { maximum: detail.required_value, }) } - (2, 4, 3, 0, 2) - if detail.flags == ACTIONABLE - && detail.requested_value > detail.required_value - && detail.resource.is_none() => + ( + layout::FailureClass::Filesystem, + layout::FailureReason::RequestPathLimit, + layout::RequiredAction::ReducePathCount, + layout::ResourceKind::None, + layout::ValueKind::PathCount, + ) if detail.flags == ACTIONABLE + && detail.requested_value > detail.required_value + && detail.resource.is_none() => { Some(RestrictionHint::ReducePathCount { requested: detail.requested_value, @@ -147,7 +168,7 @@ impl PolicyDetail { .as_deref() .ok_or("missing complete resource")?; if self.flags & RESOURCE_UNAVAILABLE != 0 - || self.resource_kind.0 == 0 + || self.resource_kind == layout::ResourceKind::None || resource.is_empty() || resource.contains(&0) { diff --git a/src/mxc-sdk/tests/streaming_processcontainer.rs b/src/mxc-sdk/tests/streaming_processcontainer.rs index 8773e79792..0a24436519 100644 --- a/src/mxc-sdk/tests/streaming_processcontainer.rs +++ b/src/mxc-sdk/tests/streaming_processcontainer.rs @@ -65,8 +65,16 @@ fn dev_json_processcontainer_policy_feedback_restricts_current_request() { }; let directory = tempfile::tempdir().expect("policy preparation workspace"); - let restricted = directory.path().join("restricted"); - let unrelated = directory.path().join("unrelated"); + let temporary_root = directory.path().to_str().expect("UTF-8 temporary path"); + let canonical_root = + match mxc_sdk::mxc_common::filesystem_canonical::canonicalize_path(temporary_root) { + mxc_sdk::mxc_common::filesystem_canonical::PathCanonical::Canonical(path) => { + std::path::PathBuf::from(path) + } + result => panic!("temporary workspace path was not canonical: {result:?}"), + }; + let restricted = canonical_root.join("restricted"); + let unrelated = canonical_root.join("unrelated"); std::fs::create_dir(&restricted).unwrap(); std::fs::create_dir(&unrelated).unwrap(); let target = restricted.join("target.txt"); From baab80f0201fe5efe9ab3177b3d5fc6384ef1178 Mon Sep 17 00:00:00 2001 From: Ross Nichols Date: Tue, 10 Nov 2026 10:59:39 -0800 Subject: [PATCH 5/5] Preserve Node policy preparation setup diagnostics Keep loader and missing-symbol errors actionable without exposing policy payload failures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/node/src/bindings/policy-preparation.ts | 71 ++++++++++++++------- sdk/node/tests/unit/binding-run.test.ts | 52 ++++++++++++--- 2 files changed, 91 insertions(+), 32 deletions(-) diff --git a/sdk/node/src/bindings/policy-preparation.ts b/sdk/node/src/bindings/policy-preparation.ts index 9bfdec5f42..19cde82afe 100644 --- a/sdk/node/src/bindings/policy-preparation.ts +++ b/sdk/node/src/bindings/policy-preparation.ts @@ -23,35 +23,60 @@ type PrepareFunction = ( type StringFreeFunction = (value: unknown) => void; type ErrorFreeFunction = (error: AbiErrorDetail) => void; +function nativeSetupError(action: 'load' | 'bind', error: unknown): MxcError { + const diagnostic = error instanceof Error && error.message.length > 0 + ? error.message + : 'Unknown native library setup error.'; + const operation = action === 'load' ? 'load' : 'bind'; + return new MxcError({ + code: 'backend_error', + message: `Unable to ${operation} the MXC native library for policy preparation: ${diagnostic}`, + remediation: 'Install or rebuild the MXC native library for this SDK version, operating system, ' + + 'and architecture, and ensure its runtime dependencies are available.', + }); +} + async function prepareBindingPolicyJsonAsyncNative( requestJson: string, policyFailuresJson: string, experimental: boolean, ): Promise { - const native = loadMxcFfi(); + let native: ReturnType; + try { + native = loadMxcFfi(); + } catch (error) { + throw nativeSetupError('load', error); + } try { - const freeString = bindNativeFunction(native.handle, { - symbol: 'mxc_string_free', - result: 'void', - parameters: ['void *'], - }); - const freeError = bindNativeFunction(native.handle, { - symbol: 'mxc_error_detail_free', - result: 'void', - parameters: [koffi.pointer(AbiErrorDetailType)], - }); - const prepare = bindNativeFunction(native.handle, { - symbol: 'mxc_prepare_policy_json', - result: 'int32_t', - parameters: [ - 'const char *', - 'const char *', - 'int32_t', - // Keep the owned char* opaque for mxc_string_free. - koffi.out(koffi.pointer('void *')), - koffi.out(koffi.pointer(AbiErrorDetailType)), - ], - }); + let freeString: ReturnType>; + let freeError: ReturnType>; + let prepare: ReturnType>; + try { + freeString = bindNativeFunction(native.handle, { + symbol: 'mxc_string_free', + result: 'void', + parameters: ['void *'], + }); + freeError = bindNativeFunction(native.handle, { + symbol: 'mxc_error_detail_free', + result: 'void', + parameters: [koffi.pointer(AbiErrorDetailType)], + }); + prepare = bindNativeFunction(native.handle, { + symbol: 'mxc_prepare_policy_json', + result: 'int32_t', + parameters: [ + 'const char *', + 'const char *', + 'int32_t', + // Keep the owned char* opaque for mxc_string_free. + koffi.out(koffi.pointer('void *')), + koffi.out(koffi.pointer(AbiErrorDetailType)), + ], + }); + } catch (error) { + throw nativeSetupError('bind', error); + } const out: unknown[] = [null]; const error = createAbiErrorDetail(); try { diff --git a/sdk/node/tests/unit/binding-run.test.ts b/sdk/node/tests/unit/binding-run.test.ts index d4cd9c2f4d..e55402183a 100644 --- a/sdk/node/tests/unit/binding-run.test.ts +++ b/sdk/node/tests/unit/binding-run.test.ts @@ -25,6 +25,7 @@ import type { CaptureDenialsResult, CaptureDenialsError, ExecutionMetadata } fro function mockNativeLibrary( t: TestContext, bind: (symbol: string, result: unknown, parameters?: unknown[]) => unknown, + loadFailure?: Error, ) { const directory = mkdtempSync('.mxc-node-binding-'); const filename = process.platform === 'win32' ? 'mxc_ffi.dll' @@ -33,13 +34,16 @@ function mockNativeLibrary( writeFileSync(path.join(directory, filename), ''); process.env.MXC_FFI_DIR = path.resolve(directory); let unloadCount = 0; - const load = t.mock.method(koffi, 'load', () => ({ - func(symbol: string, result: unknown, parameters?: unknown[]) { - if (symbol === 'const char *mxc_version(void)') return () => 'unit-fixture'; - return bind(symbol, result, parameters); - }, - unload() { unloadCount += 1; }, - })); + const load = t.mock.method(koffi, 'load', () => { + if (loadFailure !== undefined) throw loadFailure; + return { + func(symbol: string, result: unknown, parameters?: unknown[]) { + if (symbol === 'const char *mxc_version(void)') return () => 'unit-fixture'; + return bind(symbol, result, parameters); + }, + unload() { unloadCount += 1; }, + }; + }); return { get unloadCount() { return unloadCount; }, dispose() { @@ -311,7 +315,7 @@ describe('native run binding', () => { }; } assert.strictEqual(symbol, 'mxc_prepare_policy_json'); - if (mode === 'missing-symbol') throw new Error(`missing symbol: ${marker}`); + if (mode === 'missing-symbol') throw new Error('missing symbol: mxc_prepare_policy_json'); return Object.assign(() => assert.fail('synchronous preparation must not run'), { async( _request: string, _policyFailures: string, experimental: number, out: unknown[], @@ -347,7 +351,12 @@ describe('native run binding', () => { await assert.rejects(preparePolicyJson('{}', failures), (error: unknown) => { assert.ok(error instanceof MxcError); assert.strictEqual(error.code, mode === 'native' ? 'policy_validation' : 'backend_error'); - assert.strictEqual([error.message, error.stack, String(error)].join('\n').includes(marker), false); + if (mode === 'missing-symbol') { + assert.match(error.message, /mxc_prepare_policy_json/); + assert.match(error.remediation ?? '', /SDK version, operating system, and architecture/); + } else { + assert.strictEqual([error.message, error.stack, String(error)].join('\n').includes(marker), false); + } if (mode === 'native') { assert.strictEqual(error.operation, 'prepare_policy_json'); assert.strictEqual(error.nativeCode, '0x800704EC'); @@ -366,6 +375,31 @@ describe('native run binding', () => { } }); + it('preserves native library load diagnostics with actionable remediation', async (t) => { + const diagnostic = 'incompatible native binary'; + const fixture = mockNativeLibrary( + t, + () => assert.fail('native functions must not be bound after a load failure'), + new Error(diagnostic), + ); + try { + await assert.rejects(preparePolicyJson('{}', { formatVersion: 1, results: [] }), + (error: unknown) => { + assert.ok(error instanceof MxcError); + assert.strictEqual(error.code, 'backend_error'); + assert.match(error.message, new RegExp(diagnostic)); + assert.match(error.remediation ?? '', /SDK version, operating system, and architecture/); + assert.strictEqual(error.operation, undefined); + assert.strictEqual(error.nativeCode, undefined); + assert.strictEqual(error.details, undefined); + return true; + }); + assert.strictEqual(fixture.unloadCount, 0); + } finally { + fixture.dispose(); + } + }); + it('binds strict Dev spawn without requiring an execution-time policy-failure symbol', async (t) => { const symbols: string[] = []; const calls: unknown[][] = [];