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..18ee3f11c0 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,34 @@ 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. +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 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..62a24aac4c 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,8 @@ function Get-XunitSuiteFailure { [Parameter(Mandatory)][string]$ResultXml, [Parameter(Mandatory)][string[]]$Classes, [string[]]$RequiredMethods = @(), + [string[]]$RequiredTestNames = @(), + [string[]]$RequiredOutputText = @(), [switch]$RejectSkipped ) @@ -71,5 +80,18 @@ 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 { + $_.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..f6ff77fb4e 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,21 @@ 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') ` + -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') if ($failure) { throw ".NET ProcessContainer streaming: $failure" } $junit = Join-Path $env:TEMP 'process-container-node-streaming.junit.xml' @@ -222,7 +237,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..6e483e93fb --- /dev/null +++ b/scripts/ci/test-policy-preparation-test-results.ps1 @@ -0,0 +1,89 @@ +# 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') + $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 += "" + } + 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) ` + -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 (16 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..b76bc1a18b 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,628 @@ 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 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() + { + 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..6d074ccd6c --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/Dev/PreparedPolicyRequest.cs @@ -0,0 +1,226 @@ +// 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(); + MxcJson.RejectDuplicateProperties(change.Before); + MxcJson.RejectDuplicateProperties(change.After); + } + } +} + +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..19cde82afe --- /dev/null +++ b/sdk/node/src/bindings/policy-preparation.ts @@ -0,0 +1,133 @@ +// 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; + +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 { + let native: ReturnType; + try { + native = loadMxcFfi(); + } catch (error) { + throw nativeSetupError('load', error); + } + try { + 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 { + 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..414ec541ab 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,21 @@ 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 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; + }, +}); + /** * 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..e55402183a 100644 --- a/sdk/node/tests/unit/binding-run.test.ts +++ b/sdk/node/tests/unit/binding-run.test.ts @@ -2,12 +2,59 @@ // 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, + loadFailure?: Error, +) { + 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', () => { + 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() { + 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 +104,335 @@ 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: mxc_prepare_policy_json'); + 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'); + 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'); + 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('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[][] = []; + 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/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/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..3975534998 --- /dev/null +++ b/src/mxc-sdk/src/backends/process_container/common/policy_preparation_backend_tests.rs @@ -0,0 +1,433 @@ +// Copyright (c) Microsoft Corporation. +// 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; +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(); + // 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"); + 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..1c4c02c644 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 = @@ -226,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) }; @@ -299,6 +423,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 +474,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 +491,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 +529,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 +577,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 +612,7 @@ impl SecurityEnvironmentApi { ) -> Self { Self { create, + get_last_policy_result: None, query_support, version_support: None, close, @@ -483,6 +630,7 @@ impl SecurityEnvironmentApi { ) -> Self { Self { create, + get_last_policy_result: None, query_support, version_support: None, close, @@ -552,6 +700,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 +729,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 +762,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 +897,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 +1010,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..d48f841475 --- /dev/null +++ b/src/mxc-sdk/src/backends/process_container/common/secenv_policy.rs @@ -0,0 +1,234 @@ +// 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 != layout::PolicyOutcome::Blocked || details.is_empty() { + return None; + } + details + .iter() + .map(|detail| { + if detail.flags & ACTIONABLE != ACTIONABLE + || detail.flags & !KNOWN_FLAGS != layout::PolicyResultFlags::None.0 + { + return None; + } + match ( + detail.failure_class, + detail.failure_reason, + detail.required_action, + detail.resource_kind, + detail.value_kind, + ) { + ( + 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()??, + )) + } + ( + 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()??, + requested: detail.requested_value, + maximum: detail.required_value, + }) + } + ( + 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, + 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 == layout::ResourceKind::None + || 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..2f393b0392 100644 --- a/src/mxc-sdk/src/core/mxc_common/splice.rs +++ b/src/mxc-sdk/src/core/mxc_common/splice.rs @@ -59,6 +59,47 @@ 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, + 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..0a24436519 100644 --- a/src/mxc-sdk/tests/streaming_processcontainer.rs +++ b/src/mxc-sdk/tests/streaming_processcontainer.rs @@ -56,6 +56,159 @@ 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 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"); + 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() {