diff --git a/docs/architecture.md b/docs/architecture.md index 31214b112..867e32b0c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -61,6 +61,13 @@ logic. Backends with additional processes use several crates: Shared parsing and normalization live in `wxc_common`; backend-specific policy validation and enforcement live with each backend. +`mxc_engine/src/backend_registry.rs` owns backend registration metadata, +including experimental classification, keyed by the shared `ContainmentBackend` +enum. Runtime authorization consults that registry. Exact-contract publication, +build-feature availability, and host-capability probing remain separate; the +registry neither dispatches workloads nor adds backend dependencies to +`wxc_common`. + ## Request flow ```mermaid diff --git a/docs/isolation-session/state-aware-rust.md b/docs/isolation-session/state-aware-rust.md index 7fc8268b4..10d39b4bf 100644 --- a/docs/isolation-session/state-aware-rust.md +++ b/docs/isolation-session/state-aware-rust.md @@ -30,9 +30,9 @@ phase contracts after taking those routing values from CLI arguments. | Phase | `wxc-exec` | In-process | |---|---|---| -| provision / start / stop / deprovision | `wxc-exec --operation [--sandbox-id ] --config …` | `mxc_sdk::run_state_aware_json`, `mxc_state_aware` | -| exec, attached to the caller's stdio | `wxc-exec --operation exec --sandbox-id --config …` | `mxc_sdk::exec_attached`, `mxc_state_aware_exec_attached` | -| exec, caller drives the pipes | *(no CLI equivalent)* | `mxc_sdk::exec_sandbox`, `mxc_state_aware_exec` | +| provision / start / stop / deprovision | `wxc-exec --operation [--sandbox-id ] --config …` | `mxc_sdk::run_state_aware_json`, `mxc_run_state_aware_json` | +| exec, attached to the caller's stdio | `wxc-exec --operation exec --sandbox-id --config …` | `mxc_sdk::exec_attached`, `mxc_exec_state_aware_attached_json` | +| exec, caller drives the pipes | *(no CLI equivalent)* | `mxc_sdk::exec_sandbox`, `mxc_exec_state_aware_json` | Requirements on an in-process caller: diff --git a/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md b/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md index ef43aa2dc..e31c26a87 100644 --- a/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md +++ b/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md @@ -190,7 +190,7 @@ unrecognised prefix, and this is by design: | Source | Behaviour for an unrecognised `sandboxId` prefix | | ----------------------- | ------------------------------------------------ | -| SDK (TypeScript) | Throws `MxcError { code: 'malformed_id' }` before invoking `mxc_state_aware` or `mxc_state_aware_exec`. The SDK matches the prefix against the closed `StateAwareContainmentBackend` union it was compiled with; an unknown prefix is treated as a malformed id. See `sdk/node/src/state-aware-helper.ts`. | +| SDK (TypeScript) | Throws `MxcError { code: 'malformed_id' }` before invoking `mxc_run_state_aware_json` or `mxc_exec_state_aware_json`. The SDK matches the prefix against the closed `StateAwareContainmentBackend` union it was compiled with; an unknown prefix is treated as a malformed id. See `sdk/node/src/state-aware-helper.ts`. | | SDK (Rust) | `SandboxId::parse` accepts a syntactically valid opaque id without interpreting its prefix. Dispatch returns `MxcError { code: 'unsupported_containment' }` when that prefix is not registered. Empty ids, ids without prefix structure, and ids containing NUL are `malformed_id`. | | Native FFI entry points | Return `MxcError { code: 'unsupported_containment' }`. The Rust dispatcher parses the prefix successfully but the prefix-to-backend lookup table has no entry for it. See `src/core/wxc_common/src/state_aware_dispatch.rs`. | @@ -1054,7 +1054,7 @@ other state-aware backend, so caller error-handling code is portable across back | `malformed_request` | Structural request error: malformed JSON, missing required field, unknown or phase-inappropriate field, recursively unknown backend-specific field, or invalid phase-specific shape | | `unsupported_containment` | The backend named by `containment` (provision) or implied by a syntactically valid `sandboxId` prefix (non-provision) is not recognised in this build. The TypeScript SDK checks its closed prefix union before dispatch and instead throws `malformed_id`; the typed Rust SDK keeps ids opaque and therefore returns `unsupported_containment` from dispatch, matching raw FFI requests. See §6.4. | | `unsupported_phase` | The backend does not support the requested call mode (state-aware call against an ephemeral-only backend, or one-shot call against a state-aware-only backend) | -| `backend_unavailable` | The backend's runtime dependency is missing or unreachable (service not running, daemon stopped) | +| `backend_unavailable` | The backend's runtime dependency is missing or unreachable (service not running, daemon stopped), or the backend is experimental and the caller did not enable experimental features | | `malformed_id` | The `sandboxId` is structurally invalid or has a recognised prefix but does not deserialize into the backend's native form. The TypeScript SDK also uses this code for a prefix outside its closed `StateAwareContainmentBackend` union; typed Rust and raw FFI calls classify a syntactically valid unknown prefix as `unsupported_containment`. | | `stale_id` | The `sandboxId` deserialised but refers to a resource the backend no longer recognises | | `not_provisioned` | Phase requires a provisioned sandbox; none provided, or the id is in a pre-provision state | diff --git a/docs/telemetry/telemetry.md b/docs/telemetry/telemetry.md index 0fa21e625..15f6a6166 100644 --- a/docs/telemetry/telemetry.md +++ b/docs/telemetry/telemetry.md @@ -634,6 +634,7 @@ Excluded, and why: | `telemetry`, internal `test` feature | No enforcement effect. | | proxy `original_url` | Can embed `user:password@`. The host and port *are* hashed. | | `dry_run`, `testing_features_enabled` | Invocation modes, not policy. | +| `experimental_enabled` | Authorizes selecting an experimental backend, not enforcement; changing it leaves policy identity unchanged. | `network_enforcement_compatibility` is included because it changes how the normalized network policy is interpreted and enforced. diff --git a/docs/versioning.md b/docs/versioning.md index f2d0f9baa..b351469ef 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -1,5 +1,57 @@ # MXC Versioning Design +## Architecture at a glance + +Versioning determines **which configuration contract is accepted**, not which +backend implementation runs or what the host can enforce. Keep these decisions +separate: + +| Decision | Authority | +| --- | --- | +| Which fields and values exist? | The exact Rust contract types and registration in `mxc_config_contract`. Published contracts are immutable; the development contract can evolve. | +| Which exact contract does a typed SDK emit? | `sdkMajorTargets` in `schemas/schema-version.json`, checked against the exact Rust registry. Callers select a major-version SDK API, not an exact JSON version. | +| Which exact contract does raw JSON use? | The caller's declared, registered `version`. A version range or development opt-in cannot authorize another spelling. | +| Which backend and policy can run? | `mxc_engine` resolves the backend and checks authorization; host capabilities and backend validation determine what can actually be enforced. | + +Typed authoring and raw JSON converge before backend execution: + +```mermaid +flowchart LR + typed["Typed SDK policy or lifecycle request"] + target["SDK-owned published exact target"] + raw["Raw JSON with caller-declared version"] + contract["Registered exact Rust contract"] + adapter["Version-specific adapter"] + normalized["Shared normalization
ExecutionRequest and typed lifecycle operation"] + engine["Engine routing and authorization"] + backend["Host-capability validation and enforcement"] + + typed --> target --> contract + raw --> contract + contract --> adapter --> normalized --> engine --> backend +``` + +Rust builders construct exact contract values in memory. Node and .NET exact +writers serialize those values to JSON for the native boundary. In either +case, version-specific adapters and shared normalization own the conversion +to runtime requests; SDK policy types and generated schemas do not form +another native configuration authority. The +[native-ingress section](#native-ingress) identifies the deprecated binding +exceptions that remain while the migration is staged. + +Publication, SDK targeting, and runtime authorization are independent: +opening `1.1.0-alpha` does not change V1's published target, and accepting +that exact development contract does not grant experimental backend access. +Conversely, experimental authorization does not make a field legal in an +older contract or override backend policy validation. + +The sections below distinguish the [three version axes](#the-three-version-axes), +[contract shipping and parsing](#schema-shipping-model), +[SDK major targets](#high-level-sdk-major-targets), and +[backend authorization](#experimental-flag). Artifact regeneration belongs in +[Schema Code Generation](schema-codegen.md); backend execution flow is covered +by [Architecture](architecture.md). + ## Core Concepts ### Policy = Intent @@ -115,9 +167,10 @@ parser simply stops accepting those versions (the supported floor is `0.6.0-alpha`). Released schemas are never edited or deleted. The development artifact is generated from the exact - `mxc_config_contract::dev` model. It describes all eight closed one-shot and - state-aware roots, including recursively closed experimental structures, and - is the authoritative contract for declared `1.1.0-alpha` requests. +`mxc_config_contract::dev` model. It describes all eight closed one-shot and +state-aware roots, including recursively closed experimental structures. The +registered Rust types remain the authority for declared `1.1.0-alpha` +requests; the schema is their derived editor and validation artifact. Raw JSON is parsed with the exact registered contract named by its `version` field. High-level Rust, .NET, and Node v1 builders do not accept a caller-supplied @@ -217,8 +270,10 @@ accepted by v1.0. Schemas in `stable/` are immutable: they document the input shape that was promised at release. They are **not** authoritative for runtime security -defaults. `wxc-exec` is the trust boundary and may apply stricter defaults -than a stable schema declares when a security issue requires it. +defaults. Native contract parsing and backend validation form the trust +boundary for both executor and library callers. Runtime enforcement may apply +stricter defaults than a stable schema declares when a security issue requires +it. For example, an older stable schema may declare `network.defaultPolicy` defaulting to `"allow"`. The runtime may treat an @@ -234,17 +289,20 @@ Development features use their intended permanent top-level locations in the mutable exact contract. JSON location, publication eligibility, and runtime authorization are separate concerns. This gives editors full autocomplete and validation without requiring a later field move when a feature graduates. -Today, the `--experimental` flag is a global runtime toggle that enables all -features which still require authorization; per-feature gating is under -consideration. +The engine-owned backend registry in +`src/core/mxc_engine/src/backend_registry.rs` records which backend selections +require runtime experimental authorization. Contract publication does not +implicitly change that classification. The flag does not enable otherwise +invalid fields or bypass backend enforcement. **Rules:** - **Published contract contents** — shipped, stable, and immutable. - **Development contract contents** — mutable fields and roots at their permanent locations. Inclusion does not imply runtime authorization. -- **Promotion:** When a feature is ready to ship, include it in the published - exact contract and remove its runtime experimental gate. Its JSON location - does not change. +- **Promotion:** Publish the feature in an exact stable contract without + changing its JSON location. Update backend experimental classification + separately when that backend is ready for production; publishing a field + alone does not remove a backend's authorization requirement. ### Published-contract history @@ -293,6 +351,26 @@ baselines are captured when the v1.0 SDK surface is established rather than through empty placeholder descriptors. +### Native ingress + +The exact JSON execution surface uses `mxc_run_json`, `mxc_spawn_json`, and +the state-aware JSON exports. Typed binding writers select the SDK-owned +contract; raw APIs preserve the caller's exact document. These exports use +the registered contract parser and take non-configuration controls, including +experimental authorization, as typed FFI arguments rather than JSON fields. +The [SDK conformance fixtures](../tests/policy/README.md#sdk-v1-conformance-fixtures) +pair high-level invocations with independently hand-authored expected exact +documents to check mapping intent across SDKs. + +**Migration status:** Node/.NET one-shot execution and the .NET request probe +still use deprecated private binding ingress, including its legacy JSON +experimental switch. [Node migration](https://github.com/microsoft/mxc/pull/1350) +and [.NET migration](https://github.com/microsoft/mxc/pull/1351) move those +callers to exact JSON; [cleanup](https://github.com/microsoft/mxc/pull/1352) +then removes the private parser/exports and remaining SDK serde support. +Their detailed rollout is tracked in those migration changes, not by a +second configuration contract in this versioning design. + ### Experimental Flag The experimental flag must be supported at every layer of the stack: @@ -305,14 +383,16 @@ lxc-exec config.json --experimental wxc-exec.exe --experimental config.json ``` -The parser **always** parses fields defined by the selected exact contract -regardless of the flag; parsing is flag-independent. The `--experimental` flag only sets -`request.experimental_enabled`: -- When set, the runners apply the parsed experimental features alongside the - stable features -- When unset, `experimental_enabled` is false and the runners **ignore** the - parsed features that still require authorization — no error, those features - are just not applied +The parser **always** parses fields defined by the selected exact contract; +parsing is flag-independent. The flag authorizes selecting an experimental +backend (MicroVM, Hyperlight, or Windows Sandbox). Without it, native refuses +the request with +`backend_unavailable` on every one-shot and state-aware entry point. The flag +is ignored for production backends, including production-backend fields in a +development contract; unsupported policy still fails closed rather than being +silently ignored. Contract version and backend authorization are separate. +The authorization switch is excluded from policy identity because it does not +change the selected backend's enforcement. **2. SDK:** policy APIs come from `@microsoft/mxc-sdk/v1`; raw config spawning comes from `@microsoft/mxc-sdk`. diff --git a/scripts/check-dotnet-bindings-codegen.js b/scripts/check-dotnet-bindings-codegen.js index 9e73f2cc0..4c6f19c9f 100644 --- a/scripts/check-dotnet-bindings-codegen.js +++ b/scripts/check-dotnet-bindings-codegen.js @@ -42,7 +42,7 @@ function listFiles(directory) { // entry point. The generated file is excluded because it is the output under // test. const managedSource = join(repoRoot, "sdk", "dotnet", "Microsoft.Mxc.Sdk"); -const REQUIRED_ENTRY_POINTS = [ +const managedEntryPoints = [ ...new Set( listFiles(managedSource) .filter((path) => path.endsWith(".cs") && path !== generated) @@ -52,10 +52,15 @@ const REQUIRED_ENTRY_POINTS = [ .map((match) => match[1]) ), ].sort(); -if (REQUIRED_ENTRY_POINTS.length === 0) { +if (managedEntryPoints.length === 0) { console.error("ERROR: found no NativeMethods.mxc_* call sites in the C# SDK"); process.exit(1); } +// Exported entry points that no managed call site consumes yet. +const ABI_ONLY_ENTRY_POINTS = ["mxc_run_json", "mxc_spawn_json"]; +const REQUIRED_ENTRY_POINTS = [ + ...new Set([...managedEntryPoints, ...ABI_ONLY_ENTRY_POINTS]), +].sort(); // Remove any stale copy so we prove codegen actually (re)produces it. if (existsSync(generated)) { @@ -96,6 +101,8 @@ if (missing.length > 0) { const requiredSignatures = [ "mxc_run_request(byte* request_json_utf8, MxcRunResult* @out)", "mxc_spawn_request(byte* request_json_utf8, MxcSandbox** out_handle, MxcErrorDetail* out_error)", + "mxc_run_json(byte* request_json_utf8, int experimental, MxcRunResult* @out)", + "mxc_spawn_json(byte* request_json_utf8, int experimental, MxcSandbox** out_handle, MxcErrorDetail* out_error)", ]; const missingSignatures = requiredSignatures.filter( (signature) => !content.includes(signature) @@ -164,6 +171,6 @@ if (notDeclared.length > 0) { } console.log( - `C# bindings codegen OK: generated every one of ${REQUIRED_ENTRY_POINTS.length} managed entry points; ` + + `C# bindings codegen OK: generated all ${REQUIRED_ENTRY_POINTS.length} required entry points; ` + `${csbindgenInputs.length} csbindgen source(s) all declared as rerun-if-changed` ); diff --git a/scripts/versioning/validate-configs.js b/scripts/versioning/validate-configs.js index a8a29c36b..765eadba1 100644 --- a/scripts/versioning/validate-configs.js +++ b/scripts/versioning/validate-configs.js @@ -37,7 +37,13 @@ for (const required of [schemaVer.min, schemaVer.maxSupported]) { } // Directories whose *.json files (recursively) are configs we expect to validate. -const CONFIG_DIRS = [join("tests", "examples"), join("tests", "configs")]; +// `tests/policy/sdk-v1/expected` holds the exact documents every SDK must emit +// for the shared v1 policy goldens. +const CONFIG_DIRS = [ + join("tests", "examples"), + join("tests", "configs"), + join("tests", "policy", "sdk-v1", "expected"), +]; // Files that are intentionally invalid (negative tests) and must NOT validate. const exemptionsPath = join(repoRoot, "scripts", "versioning", "config-validation-exemptions.json"); diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/MxcLifecycleTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/MxcLifecycleTests.cs index 42cd84fbe..31b073488 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/MxcLifecycleTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/MxcLifecycleTests.cs @@ -74,7 +74,7 @@ public void ExecInSandbox_PassesTheExperimentalOptIn() [Fact] public void ExecInSandboxAttached_WithoutATerminal_ThrowsMalformedRequest() { - // Crosses mxc_state_aware_exec_attached itself, which the envelope tests + // Crosses mxc_exec_state_aware_attached_json itself, which the envelope tests // cannot: it is a separate entry point. That gate short-circuits ahead of // backend dispatch, which is also why this test cannot pin the // experimental opt-in. diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcLifecycle.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcLifecycle.cs index b2050ccdb..f7d915297 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcLifecycle.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcLifecycle.cs @@ -158,7 +158,7 @@ public static MxcSandboxProcess ExecInSandbox( { NativeSandbox* handle = null; MxcErrorDetail error = default; - var status = NativeMethods.mxc_state_aware_exec( + var status = NativeMethods.mxc_exec_state_aware_json( requestPtr, ExperimentalOptInFor(id), &handle, &error); if (status != (int)ErrorCode.Success) { @@ -196,7 +196,7 @@ public static SandboxWaitResult ExecInSandboxAttached( { MxcExecOutcome outcome = default; MxcErrorDetail error = default; - var status = NativeMethods.mxc_state_aware_exec_attached( + var status = NativeMethods.mxc_exec_state_aware_attached_json( requestPtr, ExperimentalOptInFor(id), &outcome, &error); if (status != (int)ErrorCode.Success) { @@ -618,7 +618,7 @@ private static void SetBackendConfig( fixed (byte* requestPtr = requestBuf) { MxcStateAwareResult result = default; - var status = NativeMethods.mxc_state_aware( + var status = NativeMethods.mxc_run_state_aware_json( requestPtr, dryRun ? 1 : 0, ExperimentalOptInFor(envelope), diff --git a/sdk/node/src/bindings/state-aware.ts b/sdk/node/src/bindings/state-aware.ts index 3b698b4c1..44c1de385 100644 --- a/sdk/node/src/bindings/state-aware.ts +++ b/sdk/node/src/bindings/state-aware.ts @@ -57,7 +57,7 @@ function bindStateAwareNativeFacade( native: ReturnType, ): StateAwareNativeFacade { const run = bindNativeFunction(native.handle, { - symbol: 'mxc_state_aware', + symbol: 'mxc_run_state_aware_json', result: 'int32_t', parameters: [ 'const char *', diff --git a/sdk/node/src/bindings/streaming.ts b/sdk/node/src/bindings/streaming.ts index 5c522f2e4..41e828e1c 100644 --- a/sdk/node/src/bindings/streaming.ts +++ b/sdk/node/src/bindings/streaming.ts @@ -137,7 +137,7 @@ function bindStreamingNativeFacade( }, stateAwareExec: bindNativeFunction(handle, { - symbol: 'mxc_state_aware_exec', + symbol: 'mxc_exec_state_aware_json', result: 'int32_t', parameters: [ 'const char *', diff --git a/sdk/node/tests/unit/state-aware.test.ts b/sdk/node/tests/unit/state-aware.test.ts index 00557a70e..74c65367d 100644 --- a/sdk/node/tests/unit/state-aware.test.ts +++ b/sdk/node/tests/unit/state-aware.test.ts @@ -515,7 +515,7 @@ describe('provisionSandbox', () => { }); }); - it('throws an MxcError carrying backend_unavailable when mxc_state_aware reports it', async () => { + it('throws an MxcError carrying backend_unavailable when mxc_run_state_aware_json reports it', async () => { installStateAwareError({ code: 'backend_unavailable', message: 'isolation session API not available on this host', diff --git a/src/core/mxc-sdk/README.md b/src/core/mxc-sdk/README.md index 713f76e2c..07399a1c9 100644 --- a/src/core/mxc-sdk/README.md +++ b/src/core/mxc-sdk/README.md @@ -53,12 +53,6 @@ BaseProcessContainer UI isolation, proxy peer identity, and denial capture. Schema 0.8 directional networking is available through `NetworkSection::{egress, ingress, runtime_config}`. -For legacy networking, a non-empty `NetworkSection::allowed_hosts` list selects -a block default even when `allow_outbound` is true, so the allowlist narrows -outbound access. With no allowlist, `allow_outbound = true` selects an allow -default and `blocked_hosts` expresses allow-all-except-these. A blocklist -without either an allowlist or `allow_outbound` is rejected. - The new ProcessContainer and directional-network configuration types are non-exhaustive so fields can be added compatibly. Construct types whose fields are all optional with `Default`, then assign the settings the request needs. diff --git a/src/core/mxc-sdk/src/lib.rs b/src/core/mxc-sdk/src/lib.rs index 8f903acbe..b05aae88d 100644 --- a/src/core/mxc-sdk/src/lib.rs +++ b/src/core/mxc-sdk/src/lib.rs @@ -212,7 +212,7 @@ pub mod v1 { StateAwareExecBackendOptions, StateAwareProvision, ValidationResult, WslcSection, }; - use crate::{Error, ErrorCode, Output, Sandbox}; + use crate::{Error, Output, Sandbox}; /// Spawn a sandbox from a [`SandboxRequest`] built by [`build_request`] (with /// the command, and any working directory / env, filled in). @@ -239,16 +239,37 @@ pub mod v1 { /// `Err` is returned when the backend can't be selected/spawned (an /// [`Error`]), or when waiting on the child fails at the OS level. pub fn run(request: SandboxRequest) -> Result { - let sandbox = spawn_sandbox(request)?; - sandbox.wait_with_output().map_err(|e| { - Error::new( - ErrorCode::BackendError, - format!("waiting for the sandbox to complete failed: {e}"), - ) - }) + crate::wait_with_output(spawn_sandbox(request)?) } } +/// Spawn a raw exact-version one-shot JSON request as a live sandbox. +/// +/// The JSON must declare an exact registered `version` and contain a one-shot +/// request. Lifecycle requests are rejected; use the state-aware JSON APIs. +/// `experimental` permits selecting an experimental backend (MicroVM, +/// Hyperlight, or Windows Sandbox), which is otherwise refused with +/// [`ErrorCode::BackendUnavailable`]. It is ignored for production backends and +/// is never read from the JSON. +pub fn spawn_sandbox_json(request_json: &str, experimental: bool) -> Result { + mxc_engine::spawn_one_shot_json(request_json, experimental).map(Sandbox::new) +} + +/// Run a raw exact-version one-shot JSON request to completion, capturing its +/// output. The JSON and `experimental` rules match [`spawn_sandbox_json`]. +pub fn run_json(request_json: &str, experimental: bool) -> Result { + wait_with_output(spawn_sandbox_json(request_json, experimental)?) +} + +fn wait_with_output(sandbox: Sandbox) -> Result { + sandbox.wait_with_output().map_err(|e| { + Error::new( + ErrorCode::BackendError, + format!("waiting for the sandbox to complete failed: {e}"), + ) + }) +} + /// Run a **state-aware lifecycle** request (as a JSON string) and return the /// response-envelope JSON string. /// @@ -263,8 +284,9 @@ pub mod v1 { /// failures) come back as an [`Error`] with the matching [`ErrorCode`]. /// /// `experimental` is the in-process equivalent of the executor's -/// `--experimental` flag. Windows Sandbox is refused with -/// [`ErrorCode::BackendUnavailable`] unless it is set, before any work is done. +/// `--experimental` flag. It permits selecting an experimental backend, which +/// is otherwise refused with [`ErrorCode::BackendUnavailable`] before any work +/// is done, and is ignored for production backends. /// It is an API parameter rather than a field in the request JSON so that a /// config cannot grant itself experimental access. pub fn run_state_aware_json( diff --git a/src/core/mxc_engine/src/backend_registry.rs b/src/core/mxc_engine/src/backend_registry.rs new file mode 100644 index 000000000..ff843daba --- /dev/null +++ b/src/core/mxc_engine/src/backend_registry.rs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Engine-owned backend registration metadata. +//! +//! Registration classifies authorization independently of contract publication, +//! build features, and host availability. Probing and dispatch stay in their +//! existing engine modules; wire names stay on the shared backend enum. + +use wxc_common::models::ContainmentBackend; + +#[derive(Debug)] +pub(crate) struct BackendRegistration { + pub(crate) backend: ContainmentBackend, + pub(crate) experimental: bool, +} + +static BACKENDS: [BackendRegistration; 10] = [ + BackendRegistration { + backend: ContainmentBackend::ProcessContainer, + experimental: false, + }, + BackendRegistration { + backend: ContainmentBackend::Wslc, + experimental: false, + }, + BackendRegistration { + backend: ContainmentBackend::Lxc, + experimental: false, + }, + BackendRegistration { + backend: ContainmentBackend::Vm, + experimental: false, + }, + BackendRegistration { + backend: ContainmentBackend::MicroVm, + experimental: true, + }, + BackendRegistration { + backend: ContainmentBackend::Hyperlight, + experimental: true, + }, + BackendRegistration { + backend: ContainmentBackend::WindowsSandbox, + experimental: true, + }, + BackendRegistration { + backend: ContainmentBackend::IsolationSession, + experimental: false, + }, + BackendRegistration { + backend: ContainmentBackend::Seatbelt, + experimental: false, + }, + BackendRegistration { + backend: ContainmentBackend::Bubblewrap, + experimental: false, + }, +]; + +/// Every new backend must select a registration; there is no implicit default. +pub(crate) fn registration(backend: &ContainmentBackend) -> &'static BackendRegistration { + let index = match backend { + ContainmentBackend::ProcessContainer => 0, + ContainmentBackend::Wslc => 1, + ContainmentBackend::Lxc => 2, + ContainmentBackend::Vm => 3, + ContainmentBackend::MicroVm => 4, + ContainmentBackend::Hyperlight => 5, + ContainmentBackend::WindowsSandbox => 6, + ContainmentBackend::IsolationSession => 7, + ContainmentBackend::Seatbelt => 8, + ContainmentBackend::Bubblewrap => 9, + }; + &BACKENDS[index] +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + + #[test] + fn registrations_have_unique_backend_keys_and_matching_lookups() { + let mut names = BTreeSet::new(); + for entry in &BACKENDS { + assert!(names.insert(entry.backend.wire_name())); + assert!(std::ptr::eq(registration(&entry.backend), entry)); + } + assert_eq!(names.len(), 10); + } + + #[test] + fn registration_preserves_the_experimental_backend_set() { + let experimental: BTreeSet<_> = BACKENDS + .iter() + .filter(|entry| entry.experimental) + .map(|entry| entry.backend.wire_name()) + .collect(); + assert_eq!( + experimental, + BTreeSet::from(["microvm", "hyperlight", "windows_sandbox"]) + ); + } +} diff --git a/src/core/mxc_engine/src/configs/process_container.rs b/src/core/mxc_engine/src/configs/process_container.rs index 73c662964..3016638e2 100644 --- a/src/core/mxc_engine/src/configs/process_container.rs +++ b/src/core/mxc_engine/src/configs/process_container.rs @@ -315,8 +315,12 @@ mod tests { ); } + /// The builder carries only the caller's capabilities. The ProcessContainer + /// backend derives `internetClient` and `privateNetworkClientServer` from + /// the normalized directional network policy, so a request built here and + /// an SDK-emitted exact document share one derivation. #[test] - fn directional_network_adds_required_capabilities() { + fn directional_network_leaves_capability_derivation_to_the_backend() { let network = NetworkSection { egress: Some(NetworkEgressSection { default: Some(NetworkAction::Allow), @@ -331,24 +335,18 @@ mod tests { let request = build_request_with_containment( &policy_with_network(Some(network)), - &Containment::ProcessContainer(ProcessContainer::default()), + &Containment::ProcessContainer(ProcessContainer { + capabilities: vec!["registryRead".to_string()], + ..ProcessContainer::default() + }), TEST_COMMAND, None, ) .expect("directional network request should build"); - assert!(request - .inner - .policy - .capabilities - .iter() - .any(|capability| capability == "internetClient")); - assert!(request - .inner - .policy - .capabilities - .iter() - .any(|capability| capability == "privateNetworkClientServer")); + assert_eq!(request.inner.policy.capabilities, ["registryRead"]); + assert!(request.inner.policy.network_egress.is_some()); + assert!(request.inner.policy.network_ingress.is_some()); } #[test] diff --git a/src/core/mxc_engine/src/experimental.rs b/src/core/mxc_engine/src/experimental.rs new file mode 100644 index 000000000..fafd5cd25 --- /dev/null +++ b/src/core/mxc_engine/src/experimental.rs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Runtime authorization for experimental backends. +//! +//! The engine's [`backend_registry`](crate::backend_registry) classifies each +//! backend as production or experimental. The caller's experimental opt-in +//! only permits selecting an experimental backend: it is ignored for +//! production backends and is independent of the contract version. One-shot +//! runner resolution and state-aware dispatch both call +//! [`require_experimental_optin`], so every entry point enforces the same rule +//! and reports the same error. + +use wxc_common::models::ContainmentBackend; +use wxc_common::mxc_error::MxcError; + +use crate::backend_registry::registration; + +/// Reject `backend` with `backend_unavailable` when it is experimental and the +/// caller has not opted in. +pub(crate) fn require_experimental_optin( + backend: &ContainmentBackend, + experimental_enabled: bool, +) -> Result<(), MxcError> { + let metadata = registration(backend); + if metadata.experimental && !experimental_enabled { + return Err(MxcError::backend_unavailable(format!( + "the '{}' backend is experimental; enable experimental features to use it", + metadata.backend.wire_name() + ))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use wxc_common::mxc_error::MxcErrorCode; + + const EXPERIMENTAL: [ContainmentBackend; 3] = [ + ContainmentBackend::MicroVm, + ContainmentBackend::Hyperlight, + ContainmentBackend::WindowsSandbox, + ]; + + const PRODUCTION: [ContainmentBackend; 7] = [ + ContainmentBackend::ProcessContainer, + ContainmentBackend::Wslc, + ContainmentBackend::Lxc, + ContainmentBackend::Vm, + ContainmentBackend::IsolationSession, + ContainmentBackend::Seatbelt, + ContainmentBackend::Bubblewrap, + ]; + + #[test] + fn experimental_backends_require_the_optin() { + for backend in EXPERIMENTAL { + let error = require_experimental_optin(&backend, false).unwrap_err(); + assert_eq!(error.code, MxcErrorCode::BackendUnavailable); + assert!(error.message.contains(backend.wire_name()), "{error:?}"); + assert!(error.message.contains("experimental"), "{error:?}"); + assert!(require_experimental_optin(&backend, true).is_ok()); + } + } + + #[test] + fn production_backends_ignore_the_optin() { + for backend in PRODUCTION { + assert!(require_experimental_optin(&backend, false).is_ok()); + assert!(require_experimental_optin(&backend, true).is_ok()); + } + } +} diff --git a/src/core/mxc_engine/src/lib.rs b/src/core/mxc_engine/src/lib.rs index 5c6634870..89f3653e6 100644 --- a/src/core/mxc_engine/src/lib.rs +++ b/src/core/mxc_engine/src/lib.rs @@ -20,7 +20,7 @@ //! port of the SDK's `createConfigFromPolicy`), for the host's native //! containment or an explicitly selected [`Containment`] backend. //! - [`spawn`] — spawn a streaming [`SandboxProcess`] handle for a request. -//! - [`run`] / [`resolve_runner`] (Windows) — run-to-completion backend +//! - [`run()`] / [`resolve_runner`] (Windows) — run-to-completion backend //! selection and execution. //! - [`run_state_aware`] — state-aware lifecycle backend resolution + dispatch. //! - [`platform_support`] / [`PlatformSupport`] — host support detection. @@ -29,9 +29,11 @@ //! - [`Error`] / [`ErrorCode`] — the crate-owned error facade over //! `wxc_common`'s internal error type. +mod backend_registry; pub mod configs; mod dispatch; mod error; +mod experimental; #[cfg(target_os = "windows")] mod guarded_capture; mod platform; @@ -84,8 +86,10 @@ pub use state_aware_sdk::{ pub use verbose_telemetry::emit_verbose_telemetry; use wxc_common::logger::{Logger, Mode}; -use wxc_common::models::{ContainmentBackend, FailurePhase, ScriptResponse}; +use wxc_common::models::{ContainmentBackend, ExecutionRequest, FailurePhase, ScriptResponse}; +use wxc_common::mxc_error::MxcError; use wxc_common::sandbox_process::{NativeStdio, SandboxProcess, StreamCloser}; +use wxc_common::state_aware_request::MxcRequest; use wxc_common::telemetry; /// Spawn a streaming [`SandboxProcess`] handle for a [`SandboxRequest`] built @@ -105,7 +109,7 @@ use wxc_common::telemetry; /// callbacks into this module's code, so **the library must remain loaded /// until every spawned handle produced by this function has been dropped** /// (which releases the corresponding provider reference through -/// [`telemetry::shutdown`] via the [`TelemetryProcess`] `Drop` impl below). +/// [`telemetry::shutdown`] via the internal `TelemetryProcess` `Drop` impl below). /// Callers that dlclose / `FreeLibrary` while a spawned handle is still live /// would leave ETW with dangling callbacks into unmapped memory. /// @@ -113,22 +117,54 @@ use wxc_common::telemetry; /// released once when the returned handle is dropped, so multiple concurrent /// spawns from the same load are safe as long as the library outlives them. pub fn spawn(request: &SandboxRequest) -> Result, Error> { + spawn_execution_request(&request.inner, Logger::new(Mode::Buffer)) +} + +/// Spawn a raw exact-version one-shot JSON request as a streaming process. +/// +/// `experimental` is the caller's runtime opt-in. It is a parameter rather than +/// a JSON field so that a configuration cannot grant itself experimental access. +/// The same library-lifetime contract as [`spawn`] applies. +pub fn spawn_one_shot_json( + request_json: &str, + experimental: bool, +) -> Result, Error> { let mut logger = Logger::new(Mode::Buffer); + let mut request = match wxc_common::config_parser::load_mxc_request_from_json( + request_json, + &mut logger, + ) + .map_err(state_aware::parse_error_to_mxc) + .map_err(Error::from)? + { + MxcRequest::OneShot(request) => request, + MxcRequest::StateAware(_) => { + return Err(Error::from(MxcError::malformed_request( + "expected a one-shot request; lifecycle requests use the state-aware JSON entry points", + ))); + } + }; + request.experimental_enabled = experimental; + spawn_execution_request(&request, logger) +} + +fn spawn_execution_request( + request: &ExecutionRequest, + mut logger: Logger, +) -> Result, Error> { let telemetry_active = request - .inner .telemetry .as_ref() .map(|config| telemetry::init(config, &mut logger)) .unwrap_or(false); let mut telemetry_registration = TelemetryRegistration::new(telemetry_active); let requested_sandbox_kind = request - .inner .telemetry .as_ref() .and_then(|config| config.requested_sandbox_kind); - let containment = request.inner.containment.clone(); + let containment = request.containment.clone(); let started = std::time::Instant::now(); - let process = match dispatch::spawn_runner(&request.inner, &mut logger) { + let process = match dispatch::spawn_runner(request, &mut logger) { Ok(process) => process, Err(error) => { // Preserve the actual error category so bounded telemetry diff --git a/src/core/mxc_engine/src/policy.rs b/src/core/mxc_engine/src/policy.rs index 7c5908c45..7b13f927e 100644 --- a/src/core/mxc_engine/src/policy.rs +++ b/src/core/mxc_engine/src/policy.rs @@ -13,6 +13,8 @@ mod exact; pub(crate) mod network; +#[cfg(test)] +mod sdk_v1_conformance; use std::borrow::Cow; use std::collections::HashSet; @@ -844,14 +846,6 @@ mod tests { assert_eq!(execution.script_code, "echo hello"); } - #[test] - fn sandbox_policy_rejects_removed_version_field() { - let error = serde_json::from_str::(r#"{ "version": "0.7.0-alpha" }"#) - .expect_err("the V1 policy must reject legacy version input"); - - assert!(error.to_string().contains("unknown field `version`")); - } - #[test] fn v1_builder_preserves_absent_empty_and_runtime_only_network_presence() { let mut policy = minimal_policy(); @@ -932,12 +926,9 @@ mod tests { // // The assertion is deliberately about key presence rather than content — // that is the only thing that distinguishes the two states downstream. - // Policies are built from JSON rather than a literal so the "caller said - // nothing about ui" case is expressed the way a real caller expresses it. #[test] fn exact_builder_preserves_absent_ui() { - let policy: super::SandboxPolicy = - serde_json::from_str("{}").expect("minimal policy parses"); + let policy = super::SandboxPolicy::default(); assert!(policy.ui.is_none(), "precondition: no ui supplied"); let request = @@ -953,8 +944,10 @@ mod tests { fn exact_builder_preserves_explicit_ui() { // An explicitly-supplied lockdown `ui` — value-identical to the old // synthesized block, which is exactly why presence is what matters. - let policy: super::SandboxPolicy = - serde_json::from_str(r#"{ "ui": {} }"#).expect("policy with ui parses"); + let policy = super::SandboxPolicy { + ui: Some(super::UiSection::default()), + ..Default::default() + }; assert!(policy.ui.is_some(), "precondition: ui supplied"); let request = @@ -1240,8 +1233,8 @@ mod tests { ui: None, timeout_ms: None, }; - // build_request resolves Seatbelt on macOS, so the config is present and - // the consumer can read its defaults and write back. + // build_request leaves `process` for the engine to resolve, so the + // setters create the Seatbelt config on first use. let mut request = build_request(&policy, TEST_COMMAND, None).expect("build_request"); let mut union: Vec = request.seatbelt_extra_mach_lookups().to_vec(); union.push("com.example.service".to_string()); @@ -1339,24 +1332,9 @@ mod tests { }) } - #[test] - fn capture_denials_config_deserializes_from_camel_case_json() { - let config: CaptureDenials = serde_json::from_value(serde_json::json!({ - "mode": "allow", - "outputPath": "/tmp/denials.json", - "retainEtl": true, - })) - .expect("config deserializes"); - - assert_eq!(config.mode, CaptureDenialsMode::Allow); - assert_eq!(config.output_path.as_deref(), Some("/tmp/denials.json")); - assert!(config.retain_etl); - } - #[test] fn capture_denials_config_defaults_to_block_without_output_path() { - let config: CaptureDenials = - serde_json::from_value(serde_json::json!({})).expect("config deserializes"); + let config = CaptureDenials::default(); assert_eq!(config.mode, CaptureDenialsMode::Block); assert!(config.output_path.is_none()); diff --git a/src/core/mxc_engine/src/policy/exact/mod.rs b/src/core/mxc_engine/src/policy/exact/mod.rs index 7a1298f0c..538410554 100644 --- a/src/core/mxc_engine/src/policy/exact/mod.rs +++ b/src/core/mxc_engine/src/policy/exact/mod.rs @@ -9,7 +9,7 @@ use wxc_common::mxc_error::MxcError; use crate::configs::{Lxc, ProcessContainer, Seatbelt}; -use super::{Containment, NetworkAction, SandboxPolicy, SandboxRequest}; +use super::{Containment, SandboxPolicy, SandboxRequest}; macro_rules! optional { ($module:ident, $value:expr) => { @@ -37,26 +37,9 @@ fn non_empty_port(value: u16, field: &str) -> Result { NonZeroU16::new(value).ok_or_else(|| error(format!("{field} must be non-zero"))) } -fn validate_common(containment: &Containment) -> Result<(), MxcError> { - if let Containment::ProcessContainer(process_container) = containment { - if process_container - .network - .as_ref() - .and_then(|network| network.allowed_proxy_peer.as_deref()) - .is_some_and(|peer| peer.trim().is_empty()) - { - return Err(error( - "processContainer.network.allowedProxyPeer must not be empty", - )); - } - } - Ok(()) -} - fn selected_process_container(containment: &Containment) -> Option { match containment { Containment::ProcessContainer(process_container) => Some(process_container.clone()), - Containment::Process if cfg!(target_os = "windows") => Some(ProcessContainer::default()), _ => None, } } @@ -64,7 +47,6 @@ fn selected_process_container(containment: &Containment) -> Option Option { match containment { Containment::Seatbelt(seatbelt) => Some(seatbelt.clone()), - Containment::Process if cfg!(target_os = "macos") => Some(Seatbelt::default()), _ => None, } } @@ -76,38 +58,6 @@ fn selected_lxc(containment: &Containment) -> Option { } } -fn normalized_capabilities( - policy: &SandboxPolicy, - process_container: &ProcessContainer, -) -> Vec { - let mut capabilities = process_container.capabilities.clone(); - if let Some(network) = policy.network.as_ref() { - let allows_internet = network.egress.as_ref().is_some_and(|egress| { - egress.default == Some(NetworkAction::Allow) - || egress.allow.as_ref().is_some_and(|rules| !rules.is_empty()) - }); - let allows_local_network = network - .ingress - .as_ref() - .is_some_and(|ingress| ingress.default == Some(NetworkAction::Allow)); - if allows_internet - && !capabilities - .iter() - .any(|capability| capability.eq_ignore_ascii_case("internetClient")) - { - capabilities.push("internetClient".to_string()); - } - if allows_local_network - && !capabilities - .iter() - .any(|capability| capability.eq_ignore_ascii_case("privateNetworkClientServer")) - { - capabilities.push("privateNetworkClientServer".to_string()); - } - } - capabilities -} - fn container_id(container_name: Option<&str>) -> String { container_name .map(str::to_string) @@ -123,7 +73,6 @@ pub(super) fn build_request( if script.is_empty() { return Err(error("script parameter is required").into()); } - validate_common(containment)?; let prepared = PreparedInput { policy, containment, @@ -162,36 +111,38 @@ mod tests { }) } - #[test] - fn rejects_empty_allowed_proxy_peer() { - let error = build_request( - &SandboxPolicy::default(), - &process_container_with_proxy_peer(""), - "echo hello", - None, - ) - .unwrap_err(); - + fn assert_blank_proxy_peer_uses_shared_validation(peer: &str) { + let policy = SandboxPolicy::default(); + let containment = process_container_with_proxy_peer(peer); + let prepared = PreparedInput { + policy: &policy, + containment: &containment, + script: "echo hello", + container_id: "proxy-validation".to_string(), + }; + let contract = ExactOneShotContract::V1_0(Box::new(v1_0::build(&prepared).unwrap())); + let mut logger = Logger::new(Mode::Buffer); + let shared_error = load_one_shot_request_from_contract(contract, &mut logger).unwrap_err(); + assert!(shared_error + .to_string() + .contains("processContainer.network.allowedProxyPeer must not be blank")); + + let error = build_request(&policy, &containment, "echo hello", None).unwrap_err(); + assert_eq!(error.code, crate::ErrorCode::MalformedRequest); assert_eq!( error.message, - "processContainer.network.allowedProxyPeer must not be empty" + format!("failed to build request: {shared_error}") ); } #[test] - fn rejects_whitespace_only_allowed_proxy_peer() { - let error = build_request( - &SandboxPolicy::default(), - &process_container_with_proxy_peer(" \t\r\n"), - "echo hello", - None, - ) - .unwrap_err(); + fn rejects_empty_allowed_proxy_peer() { + assert_blank_proxy_peer_uses_shared_validation(""); + } - assert_eq!( - error.message, - "processContainer.network.allowedProxyPeer must not be empty" - ); + #[test] + fn rejects_whitespace_only_allowed_proxy_peer() { + assert_blank_proxy_peer_uses_shared_validation(" \t\r\n"); } #[test] diff --git a/src/core/mxc_engine/src/policy/exact/v1_0.rs b/src/core/mxc_engine/src/policy/exact/v1_0.rs index 2071836c3..6b32d5857 100644 --- a/src/core/mxc_engine/src/policy/exact/v1_0.rs +++ b/src/core/mxc_engine/src/policy/exact/v1_0.rs @@ -13,10 +13,7 @@ use super::super::{ ClipboardPolicy, Containment, NetworkAction, NetworkEgressSection, NetworkIngressSection, NetworkProtocol, NetworkRuleSection, UiSection, WslcSection, }; -use super::{ - error, non_empty_port, normalized_capabilities, selected_process_container, selected_seatbelt, - PreparedInput, -}; +use super::{error, non_empty_port, selected_process_container, selected_seatbelt, PreparedInput}; fn map_ui(ui: &UiSection) -> contract::Ui { contract::Ui { @@ -223,8 +220,14 @@ pub(super) fn build(input: &PreparedInput<'_>) -> Result, _>>() .map_err(error)?; @@ -283,18 +286,14 @@ pub(super) fn build(input: &PreparedInput<'_>) -> Result contract::OneShotContainment::Process, - Containment::ProcessContainer(_) => contract::OneShotContainment::ProcessContainer, - Containment::Seatbelt(_) => contract::OneShotContainment::Seatbelt, - Containment::Lxc(_) => contract::OneShotContainment::Lxc, - Containment::Bubblewrap => contract::OneShotContainment::Bubblewrap, - Containment::Wslc(_) => contract::OneShotContainment::Wslc, - Containment::IsolationSession => contract::OneShotContainment::IsolationSession, - } + containment: contract::OptionalField::present(match containment { + Containment::Process => contract::OneShotContainment::Process, + Containment::ProcessContainer(_) => contract::OneShotContainment::ProcessContainer, + Containment::Seatbelt(_) => contract::OneShotContainment::Seatbelt, + Containment::Lxc(_) => contract::OneShotContainment::Lxc, + Containment::Bubblewrap => contract::OneShotContainment::Bubblewrap, + Containment::Wslc(_) => contract::OneShotContainment::Wslc, + Containment::IsolationSession => contract::OneShotContainment::IsolationSession, }), lifecycle: contract::OptionalField::present(contract::Lifecycle { destroy_on_exit: contract::OptionalField::present(true), diff --git a/src/core/mxc_engine/src/policy/network.rs b/src/core/mxc_engine/src/policy/network.rs index 9d3b06139..e6c7ffac6 100644 --- a/src/core/mxc_engine/src/policy/network.rs +++ b/src/core/mxc_engine/src/policy/network.rs @@ -3,15 +3,6 @@ //! Network policy authoring types. -#[cfg(test)] -#[allow(dead_code)] -#[derive(Debug, Clone, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) enum ProxySpec { - BuiltinTestServer, - Localhost(u16), -} - /// Network section of a [`SandboxPolicy`](super::SandboxPolicy). /// /// The v1 high-level SDK exposes directional policy only. Exact legacy @@ -20,23 +11,6 @@ pub(crate) enum ProxySpec { #[serde(rename_all = "camelCase", deny_unknown_fields)] #[non_exhaustive] pub struct NetworkSection { - // Retained only for legacy internal tests that exercise historical - // conversion behavior. They are absent from production builds. - #[cfg(test)] - #[allow(dead_code)] - pub(crate) allow_outbound: bool, - #[cfg(test)] - #[allow(dead_code)] - pub(crate) allow_local_network: bool, - #[cfg(test)] - #[allow(dead_code)] - pub(crate) allowed_hosts: Vec, - #[cfg(test)] - #[allow(dead_code)] - pub(crate) blocked_hosts: Vec, - #[cfg(test)] - #[allow(dead_code)] - pub(crate) proxy: Option, /// Outbound network policy. #[serde(default)] pub egress: Option, diff --git a/src/core/mxc_engine/src/policy/sdk_v1_conformance.rs b/src/core/mxc_engine/src/policy/sdk_v1_conformance.rs new file mode 100644 index 000000000..420fbfde7 --- /dev/null +++ b/src/core/mxc_engine/src/policy/sdk_v1_conformance.rs @@ -0,0 +1,525 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! SDK v1 conformance tests using `tests/policy/sdk-v1/`. +//! +//! `input/.json` describes an SDK-neutral policy invocation. This module +//! converts it to authoring types and calls [`build_request_with_containment`], +//! the request constructor exported by the Rust SDK, to get a `SandboxRequest`. +//! Its exact-contract adapter produces the internal [`ExecutionRequest`]. +//! +//! `expected/.json` is an independently hand-authored exact `1.0.0` JSON +//! request, not output generated by that constructor. [`parse_one_shot`] passes +//! it to the registered exact parser, whose version-specific adapter and shared +//! normalization produce another `ExecutionRequest`. The tests compare both +//! requests except for external-JSON diagnostic provenance. `invalid/` documents +//! must fail exact parsing with their recorded error code and diagnostic. +//! +//! The parent policy module includes this module only under `#[cfg(test)]`. +//! It lives beside the implementation to inspect the private `SandboxRequest` +//! internals without exposing them through the public SDK API. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde::Deserialize; +use wxc_common::logger::{Logger, Mode}; +use wxc_common::models::ExecutionRequest; +use wxc_common::state_aware_request::MxcRequest; + +use crate::configs::{Lxc, ProcessContainer, ProcessContainerNetwork, Seatbelt}; + +use super::{ + build_request_with_containment, ClipboardPolicy, Containment, FilesystemSection, NetworkAction, + NetworkEgressSection, NetworkIngressSection, NetworkPeerSection, NetworkPortSection, + NetworkProtocol, NetworkRuleSection, NetworkSection, RuntimeConfigSection, SandboxPolicy, + UiSection, WslcSection, +}; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ConformanceInput { + #[allow(dead_code)] + description: String, + policy: PolicyInput, + containment: ContainmentInput, + command: String, + container_name: Option, + working_directory: Option, + environment: Option>, + #[serde(default)] + inherit_default_env: bool, + telemetry: Option, +} + +#[derive(Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)] +enum ContainmentInput { + Process, + #[serde(rename_all = "camelCase")] + ProcessContainer { + #[serde(default)] + least_privilege: bool, + #[serde(default)] + learning_mode: bool, + #[serde(default)] + capabilities: Vec, + allowed_proxy_peer: Option, + }, + #[serde(rename_all = "camelCase")] + Seatbelt { + profile_override: Option, + #[serde(default)] + gui_access: bool, + #[serde(default = "default_true")] + nested_pty: bool, + #[serde(default)] + keychain_access: bool, + #[serde(default)] + extra_mach_lookups: Vec, + }, + Lxc { + distribution: String, + release: String, + }, + Bubblewrap, + #[serde(rename_all = "camelCase")] + Wslc { + image: String, + image_tar_path: Option, + cpu_count: Option, + memory_mb: Option, + #[serde(default)] + gpu: bool, + storage_path: Option, + #[serde(default)] + port_mappings: Vec<(u16, u16)>, + }, + IsolationSession, +} + +// The input files describe the high-level policy in SDK-neutral JSON. These +// test-only types read that invocation shape, not an exact config contract. + +#[derive(Default, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PolicyInput { + filesystem: Option, + network: Option, + ui: Option, + timeout_ms: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct FilesystemInput { + #[serde(default)] + readwrite_paths: Vec, + #[serde(default)] + readonly_paths: Vec, + #[serde(default)] + denied_paths: Vec, + clear_policy_on_exit: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct UiInput { + #[serde(default)] + allow_windows: bool, + #[serde(default)] + clipboard: ClipboardInput, + #[serde(default)] + allow_input_injection: bool, +} + +#[derive(Default, Deserialize)] +#[serde(rename_all = "camelCase")] +enum ClipboardInput { + #[default] + None, + Read, + Write, + All, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct NetworkInput { + egress: Option, + ingress: Option, + runtime_config: Option, +} + +#[derive(Clone, Copy, Deserialize)] +#[serde(rename_all = "lowercase")] +enum ActionInput { + Allow, + Deny, +} + +#[derive(Deserialize)] +#[serde(rename_all = "lowercase")] +enum ProtocolInput { + Tcp, + Udp, + Icmp, + Any, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct EgressInput { + default: Option, + allow: Option>, + deny: Option>, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct IngressInput { + default: Option, + host_loopback: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RuleInput { + to: Option>, + ports: Option>, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PeerInput { + cidr: String, + except: Option>, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PortInput { + protocol: Option, + port: Option, + end_port: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RuntimeConfigInput { + network_proxy: Option, +} + +impl ActionInput { + fn into_policy(self) -> NetworkAction { + match self { + Self::Allow => NetworkAction::Allow, + Self::Deny => NetworkAction::Deny, + } + } +} + +impl RuleInput { + fn into_policy(self) -> NetworkRuleSection { + NetworkRuleSection { + to: self.to.map(|peers| { + peers + .into_iter() + .map(|peer| NetworkPeerSection { + cidr: peer.cidr, + except: peer.except, + }) + .collect() + }), + ports: self.ports.map(|ports| { + ports + .into_iter() + .map(|port| NetworkPortSection { + protocol: port.protocol.map(|protocol| match protocol { + ProtocolInput::Tcp => NetworkProtocol::Tcp, + ProtocolInput::Udp => NetworkProtocol::Udp, + ProtocolInput::Icmp => NetworkProtocol::Icmp, + ProtocolInput::Any => NetworkProtocol::Any, + }), + port: port.port, + end_port: port.end_port, + }) + .collect() + }), + } + } +} + +fn rules(rules: Option>) -> Option> { + rules.map(|rules| rules.into_iter().map(RuleInput::into_policy).collect()) +} + +impl PolicyInput { + fn into_policy(self) -> SandboxPolicy { + SandboxPolicy { + filesystem: self.filesystem.map(|filesystem| FilesystemSection { + readwrite_paths: filesystem.readwrite_paths, + readonly_paths: filesystem.readonly_paths, + denied_paths: filesystem.denied_paths, + clear_policy_on_exit: filesystem.clear_policy_on_exit, + }), + network: self.network.map(|network| NetworkSection { + egress: network.egress.map(|egress| NetworkEgressSection { + default: egress.default.map(ActionInput::into_policy), + allow: rules(egress.allow), + deny: rules(egress.deny), + }), + ingress: network.ingress.map(|ingress| NetworkIngressSection { + default: ingress.default.map(ActionInput::into_policy), + host_loopback: ingress.host_loopback.map(ActionInput::into_policy), + }), + runtime_config: network.runtime_config.map(|runtime| RuntimeConfigSection { + network_proxy: runtime.network_proxy, + }), + }), + ui: self.ui.map(|ui| UiSection { + allow_windows: ui.allow_windows, + clipboard: match ui.clipboard { + ClipboardInput::None => ClipboardPolicy::None, + ClipboardInput::Read => ClipboardPolicy::Read, + ClipboardInput::Write => ClipboardPolicy::Write, + ClipboardInput::All => ClipboardPolicy::All, + }, + allow_input_injection: ui.allow_input_injection, + }), + timeout_ms: self.timeout_ms, + } + } +} + +fn default_true() -> bool { + true +} + +impl ContainmentInput { + fn into_containment(self) -> Containment { + match self { + Self::Process => Containment::Process, + Self::ProcessContainer { + least_privilege, + learning_mode, + capabilities, + allowed_proxy_peer, + } => Containment::ProcessContainer(ProcessContainer { + least_privilege, + learning_mode, + capabilities, + network: allowed_proxy_peer.map(|peer| ProcessContainerNetwork { + allowed_proxy_peer: Some(peer), + }), + ..Default::default() + }), + Self::Seatbelt { + profile_override, + gui_access, + nested_pty, + keychain_access, + extra_mach_lookups, + } => Containment::Seatbelt(Seatbelt { + profile_override, + gui_access, + nested_pty, + keychain_access, + extra_mach_lookups, + }), + Self::Lxc { + distribution, + release, + } => Containment::Lxc(Lxc { + distribution, + release, + }), + Self::Bubblewrap => Containment::Bubblewrap, + Self::Wslc { + image, + image_tar_path, + cpu_count, + memory_mb, + gpu, + storage_path, + port_mappings, + } => Containment::Wslc(WslcSection { + image, + image_tar_path, + cpu_count, + memory_mb, + gpu, + storage_path, + port_mappings, + }), + Self::IsolationSession => Containment::IsolationSession, + } + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct InvalidDocument { + #[allow(dead_code)] + description: String, + error_code: String, + message_contains: String, + document: serde_json::Value, +} + +fn fixture_dir(kind: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../tests/policy/sdk-v1") + .join(kind) +} + +fn fixture_names(kind: &str) -> Vec { + let mut names: Vec = std::fs::read_dir(fixture_dir(kind)) + .unwrap_or_else(|error| panic!("reading {kind} fixtures: {error}")) + .map(|entry| entry.unwrap().path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "json")) + .map(|path| path.file_stem().unwrap().to_string_lossy().into_owned()) + .collect(); + names.sort(); + assert!(!names.is_empty(), "no {kind} fixtures found"); + names +} + +fn read_fixture(kind: &str, name: &str) -> String { + std::fs::read_to_string(fixture_dir(kind).join(format!("{name}.json"))) + .unwrap_or_else(|error| panic!("reading {kind}/{name}.json: {error}")) +} + +fn build_from_input(name: &str, input: ConformanceInput) -> ExecutionRequest { + let containment = input.containment.into_containment(); + let policy = input.policy.into_policy(); + let mut request = build_request_with_containment( + &policy, + &containment, + &input.command, + input.container_name.as_deref(), + ) + .unwrap_or_else(|error| panic!("input/{name}.json: building the request failed: {error}")); + if let Some(working_directory) = input.working_directory { + request.set_working_directory(working_directory); + } + if let Some(environment) = input.environment { + if input.inherit_default_env { + request.inherit_default_env(environment); + } else { + request.set_env(environment); + } + } + if let Some(enabled) = input.telemetry { + request.set_telemetry_opt_in(enabled); + } + request.inner +} + +fn parse_one_shot(json: &str) -> Result { + let mut logger = Logger::new(Mode::Buffer); + match wxc_common::config_parser::load_mxc_request_from_json(json, &mut logger) + .map_err(crate::state_aware::parse_error_to_mxc)? + { + MxcRequest::OneShot(request) => Ok(request), + MxcRequest::StateAware(_) => panic!("expected a one-shot request"), + } +} + +/// The normalized intent of a request: everything except the external-JSON +/// provenance, which is diagnostic attribution rather than policy. +fn intent(request: &ExecutionRequest) -> serde_json::Value { + let mut value = serde_json::to_value(request).unwrap(); + value.as_object_mut().unwrap().remove("source_contract"); + value +} + +fn differences(path: &str, built: &serde_json::Value, parsed: &serde_json::Value) -> Vec { + match (built, parsed) { + (serde_json::Value::Object(built), serde_json::Value::Object(parsed)) => { + let mut keys: Vec<&String> = built.keys().chain(parsed.keys()).collect(); + keys.sort(); + keys.dedup(); + keys.into_iter() + .flat_map(|key| { + let null = serde_json::Value::Null; + differences( + &format!("{path}.{key}"), + built.get(key).unwrap_or(&null), + parsed.get(key).unwrap_or(&null), + ) + }) + .collect() + } + _ if built == parsed => Vec::new(), + _ => vec![format!("{path}: builder={built} expected={parsed}")], + } +} + +#[test] +fn expected_documents_match_sdk_builder_requests() { + let names = fixture_names("input"); + assert_eq!(names, fixture_names("expected"), "input/expected pairs"); + let mut failures = Vec::new(); + for name in names { + let input: ConformanceInput = serde_json::from_str(&read_fixture("input", &name)) + .unwrap_or_else(|error| panic!("input/{name}.json: {error}")); + let expected = read_fixture("expected", &name); + let document: serde_json::Value = serde_json::from_str(&expected).unwrap(); + assert_eq!(document["version"], "1.0.0", "expected/{name}.json version"); + + let built = build_from_input(&name, input); + let parsed = parse_one_shot(&expected) + .unwrap_or_else(|error| panic!("expected/{name}.json is rejected: {error}")); + + for difference in differences("", &intent(&built), &intent(&parsed)) { + failures.push(format!("{name}: {difference}")); + } + } + assert!( + failures.is_empty(), + "expected documents differ from SDK request-construction intent:\n{}", + failures.join("\n") + ); +} + +#[test] +fn invalid_documents_are_rejected() { + for name in fixture_names("invalid") { + let fixture: InvalidDocument = serde_json::from_str(&read_fixture("invalid", &name)) + .unwrap_or_else(|error| panic!("invalid/{name}.json: {error}")); + let error = match parse_one_shot(&fixture.document.to_string()) { + Ok(_) => panic!("invalid/{name}.json was accepted"), + Err(error) => error, + }; + assert_eq!( + error.code.as_str(), + fixture.error_code, + "invalid/{name}.json: {}", + error.message + ); + assert!( + error.message.contains(&fixture.message_contains), + "invalid/{name}.json was rejected for another reason: {}", + error.message + ); + } +} + +/// Without a container name every request gets a fresh random identifier. +/// SDK mappers must do the same: an absent `containerId` selects a shared +/// default container instead. +#[test] +fn unnamed_requests_mint_a_distinct_container_id() { + let policy = SandboxPolicy::default(); + let first = build_request_with_containment(&policy, &Containment::Process, "echo", None) + .unwrap() + .inner + .container_id; + let second = build_request_with_containment(&policy, &Containment::Process, "echo", None) + .unwrap() + .inner + .container_id; + assert!(!first.is_empty()); + assert_ne!(first, second); +} diff --git a/src/core/mxc_engine/src/run.rs b/src/core/mxc_engine/src/run.rs index 615b9a27b..c4c9699c2 100644 --- a/src/core/mxc_engine/src/run.rs +++ b/src/core/mxc_engine/src/run.rs @@ -77,16 +77,21 @@ impl ResolvedRunner { /// logging the selected isolation tier and any tier-selection warnings to /// `logger`, and surfacing the DACL guard in the returned [`ResolvedRunner`]. /// -/// Development backends that still require runtime authorization check -/// `request.experimental_enabled`; when it is unset they return a -/// [`malformed_request`](MxcError::malformed_request) error. Backends that are -/// not available on this host / not compiled in return an +/// Experimental backends, classified by the engine's backend registry, require +/// `request.experimental_enabled`; without it they return a +/// [`backend_unavailable`](MxcError::backend_unavailable) error before any +/// host-specific resolution. Backends that are not available on this host / +/// not compiled in return an /// [`unsupported_containment`](MxcError::unsupported_containment) error. pub fn resolve_runner( request: &ExecutionRequest, logger: &mut Logger, ) -> Result { log_policy_hash(request, logger); + crate::experimental::require_experimental_optin( + &request.containment, + request.experimental_enabled, + )?; #[cfg(target_os = "windows")] { resolve_runner_inner_windows(request, logger).map_err(Error::from) @@ -168,12 +173,17 @@ mod attribution_tests { } } -/// Resolve a runner for the `wxc-exec --audit` compatibility workflow. +/// Resolve a runner for the `wxc-exec --audit` compatibility workflow, applying +/// the same experimental-backend check as [`resolve_runner`]. #[cfg(target_os = "windows")] pub fn resolve_runner_for_audit( request: &ExecutionRequest, logger: &mut Logger, ) -> Result { + crate::experimental::require_experimental_optin( + &request.containment, + request.experimental_enabled, + )?; resolve_runner_inner_windows(request, logger).map_err(Error::from) } @@ -280,11 +290,6 @@ fn resolve_runner_inner_windows( "VM backend not yet implemented", )), ContainmentBackend::MicroVm => { - if !request.experimental_enabled { - return Err(MxcError::malformed_request( - "MicroVM is an experimental feature. Use --experimental flag.", - )); - } #[cfg(feature = "microvm")] { Ok(ResolvedRunner::without_guard(Box::new( @@ -298,13 +303,8 @@ fn resolve_runner_inner_windows( )) } } - ContainmentBackend::Hyperlight => resolve_hyperlight(request), + ContainmentBackend::Hyperlight => resolve_hyperlight(), ContainmentBackend::WindowsSandbox => { - if !request.experimental_enabled { - return Err(MxcError::malformed_request( - "Windows Sandbox is an experimental feature. Use --experimental flag.", - )); - } if let Some(ws) = &request.windows_sandbox { let default = wxc_common::models::WindowsSandboxConfig::default(); if ws.idle_timeout_ms != default.idle_timeout_ms @@ -352,13 +352,8 @@ fn resolve_runner_inner( use wxc_common::sandbox_process::Runner; match request.containment { - ContainmentBackend::Hyperlight => resolve_hyperlight(request), + ContainmentBackend::Hyperlight => resolve_hyperlight(), ContainmentBackend::MicroVm => { - if !request.experimental_enabled { - return Err(MxcError::malformed_request( - "MicroVM is an experimental feature. Use --experimental flag.", - )); - } #[cfg(feature = "microvm")] { Ok(ResolvedRunner::without_guard(Box::new( @@ -485,15 +480,9 @@ fn resolve_runner_inner( /// WHP becomes a typed error rather than a delay-load SEH exception; on /// Linux, that `/dev/kvm` opens for reading and writing, for the same reason. #[cfg(any(target_os = "windows", target_os = "linux"))] -fn resolve_hyperlight(request: &ExecutionRequest) -> Result { +fn resolve_hyperlight() -> Result { #[cfg(all(feature = "hyperlight", target_arch = "x86_64"))] { - if !request.experimental_enabled { - return Err(MxcError::malformed_request( - "Hyperlight (Hyperlight+Unikraft) is an experimental feature. \ - Use --experimental flag.", - )); - } // WHP is delay-loaded; check before setup boots a VM. #[cfg(target_os = "windows")] if !hyperlight_common::is_whp_available() { @@ -515,7 +504,6 @@ fn resolve_hyperlight(request: &ExecutionRequest) -> Result Result; + + for containment in [ + ContainmentBackend::WindowsSandbox, + ContainmentBackend::MicroVm, + ContainmentBackend::Hyperlight, + ] { + let request = ExecutionRequest { + containment, + ..Default::default() + }; + let resolvers: [Resolve; 2] = [resolve_runner, resolve_runner_for_audit]; + for resolve in resolvers { + let mut logger = Logger::new(Mode::Buffer); + let error = match resolve(&request, &mut logger) { + Ok(_) => panic!("{:?} must require the opt-in", request.containment), + Err(error) => error, + }; + assert_eq!(error.code, crate::ErrorCode::BackendUnavailable); + assert!(error.message.contains("experimental"), "{error}"); + } + } + } + #[test] fn windows_sandbox_default_settings_do_not_warn() { for config in [None, Some(WindowsSandboxConfig::default())] { diff --git a/src/core/mxc_engine/src/state_aware.rs b/src/core/mxc_engine/src/state_aware.rs index 0df4de82e..b67f1c190 100644 --- a/src/core/mxc_engine/src/state_aware.rs +++ b/src/core/mxc_engine/src/state_aware.rs @@ -57,26 +57,6 @@ fn isolation_session_unavailable() -> MxcError { ) } -/// Reject a Windows Sandbox state-aware request when the caller has not enabled -/// experimental features. Applied by both the envelope dispatcher -/// ([`run_state_aware`]) and the streaming exec dispatcher ([`exec_state_aware`]) -/// so no entry point can reach that development backend without the opt-in. -fn require_experimental_optin( - backend: &wxc_common::models::ContainmentBackend, - parsed: &ParsedStateAwareRequest, -) -> Result<(), MxcError> { - if matches!( - backend, - wxc_common::models::ContainmentBackend::WindowsSandbox - ) && !parsed.request().experimental_enabled - { - return Err(MxcError::backend_unavailable(format!( - "{backend:?} is an experimental backend; enable experimental features to use it" - ))); - } - Ok(()) -} - /// This phase's telemetry correlation vector, purely internal to MXC: no /// caller ever supplies or relays one. `provision` (whose `sandboxId` doesn't /// exist yet) mints a fresh vector; every later phase recalls the same @@ -135,7 +115,10 @@ pub fn run_state_aware( dry_run: bool, ) -> Result { let backend = resolve_backend(&parsed)?; - require_experimental_optin(&backend, &parsed)?; + crate::experimental::require_experimental_optin( + &backend, + parsed.request().experimental_enabled, + )?; match backend { #[cfg(target_os = "windows")] wxc_common::models::ContainmentBackend::WindowsSandbox => { @@ -233,7 +216,10 @@ fn run_state_aware_typed( dry_run: bool, ) -> Result { let backend = resolve_backend(&parsed)?; - require_experimental_optin(&backend, &parsed)?; + crate::experimental::require_experimental_optin( + &backend, + parsed.request().experimental_enabled, + )?; match backend { #[cfg(target_os = "windows")] wxc_common::models::ContainmentBackend::WindowsSandbox => { @@ -302,7 +288,10 @@ pub fn exec_state_aware( parsed: ParsedStateAwareRequest, ) -> Result, MxcError> { let backend = resolve_backend(&parsed)?; - require_experimental_optin(&backend, &parsed)?; + crate::experimental::require_experimental_optin( + &backend, + parsed.request().experimental_enabled, + )?; match backend { #[cfg(target_os = "windows")] wxc_common::models::ContainmentBackend::WindowsSandbox => { @@ -385,7 +374,7 @@ fn normalize_sdk_state_aware( /// Map a [`config_parser::ParseError`](wxc_common::config_parser::ParseError) to /// an [`MxcError`]. The state-aware arm already carries one; the decode, /// version, and one-shot arms carry a `WxcError` that maps to `malformed_request`. -fn parse_error_to_mxc(e: wxc_common::config_parser::ParseError) -> MxcError { +pub(crate) fn parse_error_to_mxc(e: wxc_common::config_parser::ParseError) -> MxcError { use wxc_common::config_parser::ParseError; match e { ParseError::StateAware(err) => err, @@ -1398,9 +1387,9 @@ mod tests { ) .unwrap(); - assert!(require_experimental_optin( + assert!(crate::experimental::require_experimental_optin( &wxc_common::models::ContainmentBackend::IsolationSession, - &parsed + parsed.request().experimental_enabled ) .is_ok()); } diff --git a/src/core/wxc_common/src/policy_identity.rs b/src/core/wxc_common/src/policy_identity.rs index 302125b45..ab7b15d64 100644 --- a/src/core/wxc_common/src/policy_identity.rs +++ b/src/core/wxc_common/src/policy_identity.rs @@ -12,7 +12,7 @@ //! * **sensitive** to every enforcement-relevant field, so changing one //! `readwritePaths` entry changes it; //! * **insensitive** to things that do not change enforcement (telemetry -//! settings, dry-run, testing flags); +//! settings, backend authorization, dry-run, testing flags); //! * **free of credential material**, so it cannot be used as a confirmation //! oracle against a secret embedded in a config. //! @@ -41,6 +41,7 @@ //! | `network_proxy.original_url` | A proxy URL can embed `user:password@`. The host and port *are* hashed. | //! | `capture_denials.output_path` | Only decides where the diagnostic JSON deliverable is written; not enforcement. `capture_denials.mode` remains hashed. | //! | `dry_run`, `testing_features_enabled` | Invocation modes, not policy. | +//! | `experimental_enabled` | Authorizes selecting an experimental backend; it does not change the selected backend's enforcement. | //! | `source_contract` | External JSON provenance used only for diagnostics and telemetry. Normalized network compatibility is hashed separately. | //! | `default_env_compatibility` | Decides whether a default environment block is supplied, which is process launch behavior rather than enforcement. | //! @@ -207,7 +208,8 @@ fn policy_projection(request: &ExecutionRequest) -> Value { telemetry: _excluded_telemetry, // A placeholder feature with no enforcement effect. test_feature: _excluded_test_feature, - experimental_enabled, + // Backend selection authorization is not an enforcement decision. + experimental_enabled: _excluded_experimental_authorization, // --- deliberately excluded; see the module docs --- // The command line is what runs, not the policy it runs under, and it // routinely embeds credentials. @@ -241,10 +243,6 @@ fn policy_projection(request: &ExecutionRequest) -> Value { "scriptTimeout".into(), Value::Number((*script_timeout).into()), ); - root.insert( - "experimentalEnabled".into(), - Value::Bool(*experimental_enabled), - ); root.insert( "lifecycle".into(), serde_json::to_value(lifecycle).unwrap_or(Value::Null), @@ -530,7 +528,6 @@ mod tests { std::collections::BTreeSet::from([ "containerId", "containment", - "experimentalEnabled", "lifecycle", "lxc", "networkEnforcementCompatibility", @@ -732,9 +729,40 @@ mod tests { let mut changed = request(); changed.dry_run = true; changed.testing_features_enabled = true; + changed.experimental_enabled = true; assert_eq!(baseline, policy_hash(&changed)); } + #[test] + fn backend_authorization_does_not_change_the_policy_hash() { + for backend in [ + ContainmentBackend::ProcessContainer, + ContainmentBackend::Wslc, + ContainmentBackend::Lxc, + ContainmentBackend::Vm, + ContainmentBackend::MicroVm, + ContainmentBackend::Hyperlight, + ContainmentBackend::WindowsSandbox, + ContainmentBackend::IsolationSession, + ContainmentBackend::Seatbelt, + ContainmentBackend::Bubblewrap, + ] { + let mut changed = request(); + changed.containment = backend; + let baseline = policy_hash(&changed); + changed.experimental_enabled = true; + assert_eq!( + baseline, + policy_hash(&changed), + "backend authorization must not change {} policy identity", + changed.containment.wire_name() + ); + assert!(policy_projection(&changed) + .get("experimentalEnabled") + .is_none()); + } + } + #[test] fn canonical_json_sorts_keys_at_every_depth() { let value: Value = @@ -849,6 +877,20 @@ mod tests { state_aware_policy_hash(parsed.request(), backend, parsed.operation()) } + #[test] + fn state_aware_backend_authorization_does_not_change_the_policy_hash() { + for backend in ["isolation_session", "wslc", "windows_sandbox"] { + let mut parsed = parse_state_aware(&provision_json(backend, "")); + let baseline = state_aware_policy_hash(parsed.request(), backend, parsed.operation()); + parsed.set_experimental_enabled(true); + assert_eq!( + baseline, + state_aware_policy_hash(parsed.request(), backend, parsed.operation()), + "state-aware authorization must not change {backend} policy identity" + ); + } + } + fn provision_json(backend: &str, extra_fields: &str) -> String { let network = if backend == "isolation_session" { r#","network":{"egress":{"default":"allow"},"ingress":{"default":"allow","hostLoopback":"allow"}}"# diff --git a/src/ffi/mxc_ffi/examples/attached_console_ffi.rs b/src/ffi/mxc_ffi/examples/attached_console_ffi.rs index 08a3d195d..f226f77a5 100644 --- a/src/ffi/mxc_ffi/examples/attached_console_ffi.rs +++ b/src/ffi/mxc_ffi/examples/attached_console_ffi.rs @@ -5,7 +5,7 @@ //! the C# SDK binds to — ending in an interactive shell attached to this //! console. //! -//! What this proves that no test can: that `mxc_state_aware_exec_attached` +//! What this proves that no test can: that `mxc_exec_state_aware_attached_json` //! reaches a real workload and relays it onto the caller's console. That needs //! the OS-side service and a real terminal, so it has no unattended oracle. //! @@ -25,7 +25,7 @@ use std::ffi::{CStr, CString}; use mxc_ffi::{ - mxc_error_detail_free, mxc_state_aware, mxc_state_aware_exec_attached, + mxc_error_detail_free, mxc_exec_state_aware_attached_json, mxc_run_state_aware_json, mxc_state_aware_result_free, MxcErrorDetail, MxcExecOutcome, MxcStateAwareResult, }; @@ -42,7 +42,7 @@ fn phase(request: &str) -> String { // SAFETY: valid NUL-terminated request, and `result` is live writable // storage holding no detail yet. - let status = unsafe { mxc_state_aware(json.as_ptr(), 0, 1, &mut result) }; + let status = unsafe { mxc_run_state_aware_json(json.as_ptr(), 0, 1, &mut result) }; if status != 0 { let message = if result.error.message_utf8.is_null() { String::from("(no message)") @@ -135,7 +135,7 @@ fn run() -> i32 { // SAFETY: valid request string; both out-parameters are live writable // storage holding no detail yet. Blocks until the workload exits. let status = - unsafe { mxc_state_aware_exec_attached(exec.as_ptr(), 1, &mut outcome, &mut error) }; + unsafe { mxc_exec_state_aware_attached_json(exec.as_ptr(), 1, &mut outcome, &mut error) }; if status == 0 { println!( diff --git a/src/ffi/mxc_ffi/src/lib.rs b/src/ffi/mxc_ffi/src/lib.rs index 2147e5d16..f86c4b2b6 100644 --- a/src/ffi/mxc_ffi/src/lib.rs +++ b/src/ffi/mxc_ffi/src/lib.rs @@ -5,7 +5,8 @@ //! //! This is the flat, panic-safe C surface loaded by language bindings. //! -//! - **Run to completion** — [`mxc_run_request`] accepts a binding request. +//! - **Run to completion** — [`mxc_run_json`] accepts an exact-version +//! one-shot configuration document. //! - **Host discovery** — [`mxc_available_backends_json`] reports every //! host-available backend, while [`mxc_platform_support_json`] reports the //! subset this SDK can launch. @@ -13,12 +14,32 @@ //! an exact ProcessContainer config, while //! `mxc_probe_sandbox_request_json_with_error` accepts the canonical binding //! request used by .NET. Both preserve structured failure detail. -//! - **Streaming** (`streaming` module) — [`mxc_spawn_request`] accepts the -//! same binding request and returns an opaque live handle. -//! - **State-aware lifecycle** (`state_aware` module) — [`mxc_state_aware`] -//! drives the envelope phases (provision / start / stop / deprovision), and -//! [`mxc_state_aware_exec`] runs the exec phase as a live streaming handle -//! (reusing the streaming externs). +//! - **Streaming** (`streaming` module) — [`mxc_spawn_json`] accepts the same +//! document and returns an opaque live handle. +//! - **State-aware lifecycle** (`state_aware` module) — +//! [`mxc_run_state_aware_json`] drives the envelope phases (provision / +//! start / stop / deprovision), and [`mxc_exec_state_aware_json`] runs the +//! exec phase as a live streaming handle (reusing the streaming externs). +//! - **Deprecated** — [`mxc_run_request`] and [`mxc_spawn_request`] accept the +//! private binding request. Node and .NET one-shot bindings still use them +//! until their migrations land. The .NET request probe also temporarily uses +//! `mxc_probe_sandbox_request_json_with_error`. These private paths are +//! removed after their consumers move to the exact-JSON exports. +//! +//! ## Ingress rule +//! +//! In the completed migration, policy and configuration cross this boundary +//! **only** as exact +//! versioned JSON: a public MXC configuration or state-aware envelope whose +//! `version` names a registered contract. The native side parses it with that +//! contract and rejects unknown fields and unregistered versions. Controls +//! that are not configuration, such as the experimental opt-in and dry-run, +//! are typed `i32` arguments (nonzero is true) and are never read from the +//! JSON, so a document cannot grant itself experimental access. New entry +//! points follow this rule rather than adding typed policy structs. The +//! deprecated binding-request execution and temporary probe paths are the +//! transitional exceptions: they still accept private JSON and its embedded +//! experimental switch until consumer migration and cleanup are complete. //! //! ## Contract //! @@ -38,7 +59,8 @@ //! ([`MXC_STATUS_PANIC`]), never an unwind across the boundary. //! - **Data contract**: JSON in, captured bytes + status out. The status codes //! mirror `mxc_sdk::ErrorCode` one-for-one (plus a few FFI-local codes). -//! - **Per-invocation telemetry opt-in**: request JSON uses +//! - **Per-invocation telemetry opt-in**: configuration JSON uses +//! `telemetry.enabled`; the deprecated binding request uses //! `policy.telemetry.enabled`. //! - **WSLC native co-location** (`wslc` feature, Windows): `wslcsdk.dll`, plus //! `wxc-wslc-daemon.exe` for the state-aware lifecycle, must sit beside this @@ -63,9 +85,11 @@ use std::ptr; use std::sync::OnceLock; use mxc_sdk::v1::{run, SandboxRequest}; -use mxc_sdk::{available_backends, platform_support, ErrorCode, WaitOutcome}; +use mxc_sdk::{ + available_backends, platform_support, run_json, Error, ErrorCode, Output, WaitOutcome, +}; #[cfg(target_os = "windows")] -use mxc_sdk::{v1::probe, Error, ProbeOutput}; +use mxc_sdk::{v1::probe, ProbeOutput}; mod error_detail; mod request; @@ -317,7 +341,7 @@ pub(crate) fn alloc_cstring(bytes: &[u8]) -> *mut c_char { } } -/// Free a `CString` previously produced by [`alloc_cstring`] / [`into_raw`], +/// Free a `CString` previously produced by [`alloc_cstring`] / [`CString::into_raw`], /// resetting the pointer to null. pub(crate) fn free_cstr(p: &mut *mut c_char) { if !p.is_null() { @@ -348,10 +372,14 @@ pub(crate) unsafe fn cstr_to_str<'a>(p: *const c_char) -> Option<&'a str> { /// /// `request_json_utf8` is the co-versioned binding request document. /// +/// **Deprecated:** use [`mxc_run_json`] with an exact-version configuration. +/// This export and the binding request it accepts will be removed. +/// /// # Safety /// - `request_json_utf8` must be null or valid NUL-terminated UTF-8. /// - `out` must be null or point to writable [`MxcRunResult`]-sized storage. -/// - On success the caller must release `*out` with [`mxc_run_result_free`]. +/// - The caller must release `*out` with [`mxc_run_result_free`] after every +/// call that populated it, including failures. #[no_mangle] pub unsafe extern "C" fn mxc_run_request( request_json_utf8: *const c_char, @@ -388,8 +416,60 @@ fn run_request_inner(request_json_utf8: *const c_char) -> MxcRunResult { execute_request(request) } +/// Run a raw exact-version one-shot JSON request to completion and capture its +/// output. +/// +/// `request_json_utf8` is a public MXC configuration with an exact registered +/// `version`, unlike the private binding request accepted by +/// [`mxc_run_request`]. `experimental` is nonzero to permit an experimental +/// backend, which is otherwise refused with `backend_unavailable`; it is +/// ignored for production backends and never read from the JSON. +/// +/// # Safety +/// - `request_json_utf8` must be null or valid NUL-terminated UTF-8. +/// - `out` must be null or point to writable [`MxcRunResult`]-sized storage. +/// - The caller must release `*out` with [`mxc_run_result_free`] after every +/// call that populated it, including failures. +#[no_mangle] +pub unsafe extern "C" fn mxc_run_json( + request_json_utf8: *const c_char, + experimental: i32, + out: *mut MxcRunResult, +) -> i32 { + if out.is_null() { + return MXC_STATUS_NULL_ARGUMENT; + } + + let result = catch_unwind(|| run_json_inner(request_json_utf8, experimental != 0)) + .unwrap_or_else(|panic| { + report_panic("mxc_run_json", &*panic); + MxcRunResult::error(MXC_STATUS_PANIC, "the mxc engine panicked") + }); + + let status = result.status; + // SAFETY: `out` is non-null and caller-guaranteed writable. + unsafe { ptr::write(out, result) }; + status +} + +fn run_json_inner(request_json_utf8: *const c_char, experimental: bool) -> MxcRunResult { + // SAFETY: caller contract on `mxc_run_json`; borrowed only within scope. + let request_json = match unsafe { cstr_to_str(request_json_utf8) } { + Some(value) => value, + None if request_json_utf8.is_null() => { + return MxcRunResult::error(MXC_STATUS_NULL_ARGUMENT, "request JSON pointer is null") + } + None => return MxcRunResult::error(MXC_STATUS_INVALID_UTF8, "request JSON is not UTF-8"), + }; + execute_output(run_json(request_json, experimental)) +} + fn execute_request(request: SandboxRequest) -> MxcRunResult { - match run(request) { + execute_output(run(request)) +} + +fn execute_output(output: Result) -> MxcRunResult { + match output { Ok(output) => { let (exit_code, timed_out) = match output.outcome { WaitOutcome::Exited(code) => (code, 0), diff --git a/src/ffi/mxc_ffi/src/request.rs b/src/ffi/mxc_ffi/src/request.rs index 7797734ee..3d5655f84 100644 --- a/src/ffi/mxc_ffi/src/request.rs +++ b/src/ffi/mxc_ffi/src/request.rs @@ -2,6 +2,17 @@ // Licensed under the MIT License. //! Co-versioned JSON request contract used by language bindings. +//! +//! **Deprecated.** This private binding request backs the deprecated +//! [`mxc_run_request`](crate::mxc_run_request) and +//! [`mxc_spawn_request`](crate::streaming::mxc_spawn_request) exports and the +//! temporary .NET binding-request probe. Node and .NET one-shot bindings still +//! use this request until their migrations switch to +//! [`mxc_run_json`](crate::mxc_run_json) and +//! [`mxc_spawn_json`](crate::streaming::mxc_spawn_json) instead; this module is +//! removed with the private execution and probe exports. This transitional +//! format still reads experimental authorization from its own JSON; exact +//! configuration entry points accept authorization only as typed arguments. use std::collections::BTreeMap; @@ -434,6 +445,26 @@ fn malformed_request(error: serde_json::Error) -> Error { mod tests { use super::*; + #[test] + fn capture_denials_parse_from_camel_case_json() { + let capture: CaptureDenials = serde_json::from_value(serde_json::json!({ + "mode": "allow", + "outputPath": "/tmp/denials.json", + "retainEtl": true, + })) + .expect("captureDenials parses"); + assert_eq!( + capture.mode, + mxc_sdk::v1::configs::CaptureDenialsMode::Allow + ); + assert_eq!(capture.output_path.as_deref(), Some("/tmp/denials.json")); + assert!(capture.retain_etl); + + let capture: CaptureDenials = + serde_json::from_value(serde_json::json!({})).expect("empty captureDenials parses"); + assert_eq!(capture, CaptureDenials::default()); + } + #[test] fn process_container_filesystem_is_accepted_by_native_contract() { let spec: RequestSpec = serde_json::from_str( diff --git a/src/ffi/mxc_ffi/src/state_aware.rs b/src/ffi/mxc_ffi/src/state_aware.rs index af8abbcf8..ffe6fd8a5 100644 --- a/src/ffi/mxc_ffi/src/state_aware.rs +++ b/src/ffi/mxc_ffi/src/state_aware.rs @@ -6,14 +6,15 @@ //! Three entry points mirror the SDK's [`mxc_sdk::run_state_aware_json`], //! [`mxc_sdk::exec_attached`] and [`mxc_sdk::exec_sandbox`]: //! -//! - [`mxc_state_aware`] drives the **envelope phases** (`provision` / `start` / -//! `stop` / `deprovision`, and a dry run of any phase): JSON request in, JSON -//! response envelope out, filled into an [`MxcStateAwareResult`]. -//! - [`mxc_state_aware_exec_attached`] drives the **exec phase attached to this -//! process's stdio** — what an embedding console application needs for an -//! interactive terminal. It blocks and reports an [`MxcExecOutcome`]. -//! - [`mxc_state_aware_exec`] drives the **exec phase as a live streaming** -//! process, returning the same opaque [`MxcSandbox`](crate::MxcSandbox) handle +//! - [`mxc_run_state_aware_json`] drives the **envelope phases** (`provision` / +//! `start` / `stop` / `deprovision`, and a dry run of any phase): JSON +//! request in, JSON response envelope out, filled into an +//! [`MxcStateAwareResult`]. +//! - [`mxc_exec_state_aware_attached_json`] drives the **exec phase attached +//! to this process's stdio** — what an embedding console application needs +//! for an interactive terminal. It blocks and reports an [`MxcExecOutcome`]. +//! - [`mxc_exec_state_aware_json`] drives the **exec phase as a live streaming** +//! process, returning the same opaque [`crate::MxcSandbox`] handle //! as [`mxc_spawn_request`](crate::mxc_spawn_request) — so the caller reuses the //! `mxc_stream_*` / `mxc_sandbox_*` externs to read/write/wait/kill. //! @@ -37,7 +38,7 @@ use crate::{ MXC_STATUS_INVALID_UTF8, MXC_STATUS_NULL_ARGUMENT, MXC_STATUS_PANIC, MXC_STATUS_SUCCESS, }; -/// The result of an [`mxc_state_aware`] call. +/// The result of an [`mxc_run_state_aware_json`] call. /// /// On success (`status == 0`), `response_json_utf8` holds the response-envelope /// JSON (every field of `error` is null). On failure, `error` carries the @@ -94,25 +95,28 @@ impl MxcStateAwareResult { /// Parses `request_json_utf8` (the wire-format request, with a `phase` field), /// runs the requested phase, and writes the outcome into `*out`. A non-dry-run /// `exec` produces no envelope and is rejected here — run it through -/// [`mxc_state_aware_exec_attached`] to attach the workload to this process's -/// stdio, or [`mxc_state_aware_exec`] to drive the pipes directly. +/// [`mxc_exec_state_aware_attached_json`] to attach the workload to this +/// process's stdio, or [`mxc_exec_state_aware_json`] to drive the pipes +/// directly. /// /// Returns the resulting status code (also stored in `out->status`). Returns /// [`MXC_STATUS_NULL_ARGUMENT`] **without running the phase** if `out` is null: /// the caller has nowhere to receive a sandbox id, so provisioning one would /// strand it — nothing else can reclaim a sandbox whose only handle was -/// discarded. [`mxc_state_aware_exec`] checks its out-parameter first for the -/// same reason. +/// discarded. [`mxc_exec_state_aware_json`] checks its out-parameter first for +/// the same reason. /// -/// `experimental` is non-zero to opt in to Windows Sandbox; with zero that -/// backend is refused with `backend_unavailable` before any work is done. +/// `experimental` is nonzero to permit an experimental backend such as Windows +/// Sandbox; with zero such a backend is refused with `backend_unavailable` +/// before any work is done. Production backends ignore it. /// /// # Safety /// - `request_json_utf8` must be null or a valid NUL-terminated UTF-8 C string. /// - `out` must be null or point to writable [`MxcStateAwareResult`]-sized storage. -/// - On success the caller must release `*out` with [`mxc_state_aware_result_free`]. +/// - The caller must release `*out` with [`mxc_state_aware_result_free`] after +/// every call that populated it, including failures. #[no_mangle] -pub unsafe extern "C" fn mxc_state_aware( +pub unsafe extern "C" fn mxc_run_state_aware_json( request_json_utf8: *const c_char, dry_run: i32, experimental: i32, @@ -126,7 +130,7 @@ pub unsafe extern "C" fn mxc_state_aware( state_aware_inner(request_json_utf8, dry_run != 0, experimental != 0) })) .unwrap_or_else(|panic| { - crate::report_panic("mxc_state_aware", &*panic); + crate::report_panic("mxc_run_state_aware_json", &*panic); MxcStateAwareResult::error(MXC_STATUS_PANIC, "the mxc engine panicked") }); @@ -142,7 +146,8 @@ fn state_aware_inner( dry_run: bool, experimental: bool, ) -> MxcStateAwareResult { - // SAFETY: caller contract on `mxc_state_aware`; borrowed only within scope. + // SAFETY: caller contract on `mxc_run_state_aware_json`; borrowed only + // within scope. let request_json = match unsafe { cstr_to_str(request_json_utf8) } { Some(s) => s, None if request_json_utf8.is_null() => { @@ -167,11 +172,11 @@ fn state_aware_inner( } /// Free the owned out-strings of an [`MxcStateAwareResult`] produced by -/// [`mxc_state_aware`]. Idempotent; the struct itself is caller-owned. +/// [`mxc_run_state_aware_json`]. Idempotent; the struct itself is caller-owned. /// /// # Safety -/// `r` must be null or point to a result previously filled by [`mxc_state_aware`], -/// not already freed. +/// `r` must be null or point to a result previously filled by +/// [`mxc_run_state_aware_json`], not already freed. #[no_mangle] pub unsafe extern "C" fn mxc_state_aware_result_free(r: *mut MxcStateAwareResult) { if r.is_null() { @@ -189,15 +194,15 @@ pub unsafe extern "C" fn mxc_state_aware_result_free(r: *mut MxcStateAwareResult /// /// Parses `request_json_utf8` (an `exec`-phase request with a `sandboxId`), /// spawns the process, and on success writes an opaque -/// [`MxcSandbox`](crate::MxcSandbox) handle to `*out_handle` (drive it with the +/// [`crate::MxcSandbox`] handle to `*out_handle` (drive it with the /// `mxc_stream_*` / `mxc_sandbox_*` externs, free it with `mxc_sandbox_free`). /// On failure returns the status code and, if `out_error` is non-null, fills it /// with the message plus the failing API call when there was one (release it /// with [`mxc_error_detail_free`](crate::mxc_error_detail_free)); /// `*out_handle` is set to null. /// -/// `experimental` opts in to Windows Sandbox, as for [`mxc_state_aware`]. -/// IsolationSession and WSLc require no runtime experimental opt-in. +/// `experimental` permits an experimental backend, as for +/// [`mxc_run_state_aware_json`]. /// /// # Safety /// - `request_json_utf8` must be null or a valid NUL-terminated UTF-8 C string. @@ -213,7 +218,7 @@ pub unsafe extern "C" fn mxc_state_aware_result_free(r: *mut MxcStateAwareResult /// overwrites that storage without freeing what was there, so handing it a /// populated detail leaks that detail's strings. #[no_mangle] -pub unsafe extern "C" fn mxc_state_aware_exec( +pub unsafe extern "C" fn mxc_exec_state_aware_json( request_json_utf8: *const c_char, experimental: i32, out_handle: *mut *mut MxcSandbox, @@ -258,7 +263,7 @@ pub unsafe extern "C" fn mxc_state_aware_exec( }) })) .unwrap_or_else(|panic| { - crate::report_panic("mxc_state_aware_exec", &*panic); + crate::report_panic("mxc_exec_state_aware_json", &*panic); Err(( MXC_STATUS_PANIC, MxcErrorDetail::from_message("the mxc engine panicked"), @@ -269,7 +274,8 @@ pub unsafe extern "C" fn mxc_state_aware_exec( unsafe { crate::streaming::finish_spawn(outcome, out_handle, out_error) } } -/// How an attached exec finished, filled by [`mxc_state_aware_exec_attached`]. +/// How an attached exec finished, filled by +/// [`mxc_exec_state_aware_attached_json`]. /// /// `timed_out` is a separate field so a timeout stays additive to this struct's /// layout. @@ -321,11 +327,11 @@ fn exec_outcome_to_abi(outcome: WaitOutcome) -> MxcExecOutcome { /// what happened. /// - `out_error` must be null, or point to writable storage for one /// [`MxcErrorDetail`] that holds **no live detail** — see -/// [`mxc_state_aware_exec`] for why. +/// [`mxc_exec_state_aware_json`] for why. /// - Nothing here is owned by the caller except `*out_error`, released with /// [`mxc_error_detail_free`](crate::mxc_error_detail_free). #[no_mangle] -pub unsafe extern "C" fn mxc_state_aware_exec_attached( +pub unsafe extern "C" fn mxc_exec_state_aware_attached_json( request_json_utf8: *const c_char, experimental: i32, out_outcome: *mut MxcExecOutcome, @@ -366,7 +372,7 @@ pub unsafe extern "C" fn mxc_state_aware_exec_attached( }) })) .unwrap_or_else(|panic| { - crate::report_panic("mxc_state_aware_exec_attached", &*panic); + crate::report_panic("mxc_exec_state_aware_attached_json", &*panic); Err(( MXC_STATUS_PANIC, MxcErrorDetail::from_message("the mxc engine panicked"), @@ -411,8 +417,9 @@ mod tests { let j = CString::new(json).unwrap(); let mut out = MxcStateAwareResult::empty(); // SAFETY: valid string and out pointer. - let status = - unsafe { mxc_state_aware(j.as_ptr(), dry_run as i32, experimental as i32, &mut out) }; + let status = unsafe { + mxc_run_state_aware_json(j.as_ptr(), dry_run as i32, experimental as i32, &mut out) + }; assert_eq!(status, out.status); out } @@ -453,7 +460,14 @@ mod tests { ); } - result.error.free_strings(); + // SAFETY: the result owns only allocations produced by this library. + unsafe { mxc_state_aware_result_free(&mut result) }; + assert_eq!(result.status, crate::MXC_STATUS_STALE_ID); + assert!(result.response_json_utf8.is_null()); + assert!(result.error.message_utf8.is_null()); + assert!(result.error.operation_utf8.is_null()); + assert!(result.error.native_code_utf8.is_null()); + assert!(result.error.remediation_utf8.is_null()); } #[test] @@ -465,7 +479,7 @@ mod tests { assert_eq!(out.status, crate::MXC_STATUS_MALFORMED_REQUEST); assert!(out.response_json_utf8.is_null()); assert!(!out.error.message_utf8.is_null()); - // SAFETY: filled by `mxc_state_aware`. + // SAFETY: filled by `mxc_run_state_aware_json`. unsafe { mxc_state_aware_result_free(&mut out) }; assert!(out.error.message_utf8.is_null()); } @@ -497,7 +511,7 @@ mod tests { assert!(message.contains("column "), "{message}"); assert!(out.error.operation_utf8.is_null()); assert!(out.error.native_code_utf8.is_null()); - // SAFETY: all result strings were allocated by mxc_state_aware. + // SAFETY: all result strings were allocated by mxc_run_state_aware_json. unsafe { mxc_state_aware_result_free(&mut out) }; } } @@ -526,7 +540,7 @@ mod tests { assert_eq!(message, "appId must be at most 256 characters (got 257)"); assert!(out.error.operation_utf8.is_null()); assert!(out.error.native_code_utf8.is_null()); - // SAFETY: all result strings were allocated by mxc_state_aware. + // SAFETY: all result strings were allocated by mxc_run_state_aware_json. unsafe { mxc_state_aware_result_free(&mut out) }; } @@ -537,7 +551,7 @@ mod tests { false, ); assert_eq!(out.status, crate::MXC_STATUS_MALFORMED_REQUEST); - // SAFETY: filled by `mxc_state_aware`. + // SAFETY: filled by `mxc_run_state_aware_json`. unsafe { mxc_state_aware_result_free(&mut out) }; } @@ -553,7 +567,7 @@ mod tests { false, ); assert_eq!(out.status, crate::MXC_STATUS_UNSUPPORTED_CONTAINMENT); - // SAFETY: filled by `mxc_state_aware`. + // SAFETY: filled by `mxc_run_state_aware_json`. unsafe { mxc_state_aware_result_free(&mut out) }; } @@ -561,10 +575,10 @@ mod tests { fn null_request_reports_null_argument() { let mut out = MxcStateAwareResult::empty(); // SAFETY: null request is explicitly handled; valid out pointer. - let status = unsafe { mxc_state_aware(ptr::null(), 0, 0, &mut out) }; + let status = unsafe { mxc_run_state_aware_json(ptr::null(), 0, 0, &mut out) }; assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); assert!(!out.error.message_utf8.is_null()); - // SAFETY: filled by `mxc_state_aware`. + // SAFETY: filled by `mxc_run_state_aware_json`. unsafe { mxc_state_aware_result_free(&mut out) }; } @@ -575,7 +589,7 @@ mod tests { ) .unwrap(); // SAFETY: valid string, deliberately-null out. - let status = unsafe { mxc_state_aware(j.as_ptr(), 0, 0, ptr::null_mut()) }; + let status = unsafe { mxc_run_state_aware_json(j.as_ptr(), 0, 0, ptr::null_mut()) }; assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); } @@ -585,7 +599,7 @@ mod tests { CString::new(r#"{"version":"0.9.0-alpha","phase":"exec","sandboxId":"x:y"}"#).unwrap(); // SAFETY: valid string, deliberately-null out_handle. let status = - unsafe { mxc_state_aware_exec(j.as_ptr(), 0, ptr::null_mut(), ptr::null_mut()) }; + unsafe { mxc_exec_state_aware_json(j.as_ptr(), 0, ptr::null_mut(), ptr::null_mut()) }; assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); } @@ -598,11 +612,11 @@ mod tests { let mut handle: *mut MxcSandbox = ptr::null_mut(); let mut err = MxcErrorDetail::none(); // SAFETY: valid string and out pointers. - let status = unsafe { mxc_state_aware_exec(j.as_ptr(), 0, &mut handle, &mut err) }; + let status = unsafe { mxc_exec_state_aware_json(j.as_ptr(), 0, &mut handle, &mut err) }; assert_eq!(status, crate::MXC_STATUS_MALFORMED_REQUEST); assert!(handle.is_null()); assert!(!err.message_utf8.is_null()); - // SAFETY: `err` was filled by `mxc_state_aware_exec` and not yet freed. + // SAFETY: `err` was filled by `mxc_exec_state_aware_json` and not yet freed. unsafe { crate::mxc_error_detail_free(&mut err) }; } @@ -617,7 +631,7 @@ mod tests { assert!(out.error.operation_utf8.is_null()); assert!(out.error.native_code_utf8.is_null()); assert!(out.error.remediation_utf8.is_null()); - // SAFETY: filled by `mxc_state_aware`. + // SAFETY: filled by `mxc_run_state_aware_json`. unsafe { mxc_state_aware_result_free(&mut out) }; } @@ -638,7 +652,7 @@ mod tests { crate::MXC_STATUS_MALFORMED_REQUEST, "the exact request must reach backend dispatch" ); - // SAFETY: filled by `mxc_state_aware`. + // SAFETY: filled by `mxc_run_state_aware_json`. unsafe { mxc_state_aware_result_free(&mut out) }; } @@ -659,20 +673,21 @@ mod tests { let mut handle: *mut MxcSandbox = ptr::null_mut(); let mut err = MxcErrorDetail::none(); // SAFETY: valid string and out pointers. - let status = - unsafe { mxc_state_aware_exec(j.as_ptr(), experimental, &mut handle, &mut err) }; + let status = unsafe { + mxc_exec_state_aware_json(j.as_ptr(), experimental, &mut handle, &mut err) + }; assert!(handle.is_null(), "no handle is produced either way"); if expect_refused { assert_eq!(status, crate::MXC_STATUS_BACKEND_UNAVAILABLE); } else { assert_ne!(status, crate::MXC_STATUS_BACKEND_UNAVAILABLE); } - // SAFETY: filled by `mxc_state_aware_exec` and not yet freed. + // SAFETY: filled by `mxc_exec_state_aware_json` and not yet freed. unsafe { crate::mxc_error_detail_free(&mut err) }; } } - // ===== mxc_state_aware_exec_attached ===== + // ===== mxc_exec_state_aware_attached_json ===== fn attached(json: &str, experimental: bool) -> (i32, MxcExecOutcome, MxcErrorDetail) { let j = CString::new(json).unwrap(); @@ -683,7 +698,12 @@ mod tests { let mut err = MxcErrorDetail::none(); // SAFETY: valid string and out pointers. let status = unsafe { - mxc_state_aware_exec_attached(j.as_ptr(), experimental as i32, &mut outcome, &mut err) + mxc_exec_state_aware_attached_json( + j.as_ptr(), + experimental as i32, + &mut outcome, + &mut err, + ) }; (status, outcome, err) } @@ -697,7 +717,7 @@ mod tests { let mut err = MxcErrorDetail::none(); // SAFETY: null request is the case under test; out pointers are valid. let status = - unsafe { mxc_state_aware_exec_attached(ptr::null(), 1, &mut outcome, &mut err) }; + unsafe { mxc_exec_state_aware_attached_json(ptr::null(), 1, &mut outcome, &mut err) }; assert_eq!(status, crate::MXC_STATUS_NULL_ARGUMENT); assert!(!err.message_utf8.is_null()); // SAFETY: filled above and not yet freed. @@ -714,7 +734,7 @@ mod tests { let mut err = MxcErrorDetail::none(); // SAFETY: null outcome storage is the case under test. let status = - unsafe { mxc_state_aware_exec_attached(j.as_ptr(), 1, ptr::null_mut(), &mut err) }; + unsafe { mxc_exec_state_aware_attached_json(j.as_ptr(), 1, ptr::null_mut(), &mut err) }; assert_eq!(status, crate::MXC_STATUS_NULL_ARGUMENT); // SAFETY: zeroed above; freeing a none-detail is a no-op. unsafe { crate::mxc_error_detail_free(&mut err) }; diff --git a/src/ffi/mxc_ffi/src/streaming.rs b/src/ffi/mxc_ffi/src/streaming.rs index 174a0c123..6595e98dc 100644 --- a/src/ffi/mxc_ffi/src/streaming.rs +++ b/src/ffi/mxc_ffi/src/streaming.rs @@ -65,7 +65,7 @@ use std::panic::{catch_unwind, AssertUnwindSafe}; use std::ptr; use mxc_sdk::v1::spawn_sandbox; -use mxc_sdk::{Sandbox, StreamCloser, WaitOutcome}; +use mxc_sdk::{spawn_sandbox_json, Sandbox, StreamCloser, WaitOutcome}; use crate::{ alloc_cstring, cstr_to_str, request, status_from_error_code, MxcErrorDetail, @@ -86,7 +86,7 @@ pub struct MxcSandbox { impl MxcSandbox { /// Wrap an [`mxc_sdk::Sandbox`] as an opaque FFI handle. Used by both the /// one-shot spawn path ([`mxc_spawn_request`]) and the state-aware streaming exec - /// path (`mxc_state_aware_exec`). + /// path (`mxc_exec_state_aware_json`). pub(crate) fn new(inner: Sandbox) -> Self { Self { inner } } @@ -145,6 +145,9 @@ impl MxcNativeStdio { /// Uses the same co-versioned request JSON contract as /// [`mxc_run_request`](crate::mxc_run_request). /// +/// **Deprecated:** use [`mxc_spawn_json`] with an exact-version configuration. +/// This export and the binding request it accepts will be removed. +/// /// # Safety /// - `request_json_utf8` must be null or valid NUL-terminated UTF-8. /// - `out_handle` must point to writable pointer-sized storage holding no live @@ -203,8 +206,80 @@ fn spawn_request_inner(request_json_utf8: *const c_char) -> Result i32 { + if !out_handle.is_null() { + // SAFETY: caller-guaranteed writable pointer-sized storage. + unsafe { *out_handle = ptr::null_mut() }; + } + if !out_error.is_null() { + // SAFETY: caller-guaranteed writable storage for one fresh detail. + unsafe { ptr::write(out_error, MxcErrorDetail::none()) }; + } + if out_handle.is_null() { + return MXC_STATUS_NULL_ARGUMENT; + } + let outcome = catch_unwind(AssertUnwindSafe(|| { + spawn_json_inner(request_json_utf8, experimental != 0) + })) + .unwrap_or_else(|panic| { + crate::report_panic("mxc_spawn_json", &*panic); + Err(( + MXC_STATUS_PANIC, + MxcErrorDetail::from_message("the mxc engine panicked"), + )) + }); + + // SAFETY: `out_handle` is non-null and `out_error` is null or writable. + unsafe { finish_spawn(outcome, out_handle, out_error) } +} + +fn spawn_json_inner( + request_json_utf8: *const c_char, + experimental: bool, +) -> Result { + // SAFETY: caller contract on `mxc_spawn_json`; borrowed only within scope. + let request_json = match unsafe { cstr_to_str(request_json_utf8) } { + Some(value) => value, + None if request_json_utf8.is_null() => { + return Err(( + MXC_STATUS_NULL_ARGUMENT, + MxcErrorDetail::from_message("request JSON pointer is null"), + )) + } + None => { + return Err(( + MXC_STATUS_INVALID_UTF8, + MxcErrorDetail::from_message("request JSON is not UTF-8"), + )) + } + }; + spawn_sandbox_json(request_json, experimental).map_err(sdk_error_detail) +} + /// Shared tail of the handle-returning spawn entry points -/// ([`mxc_spawn_request`] and `mxc_state_aware_exec`): on success box the +/// ([`mxc_spawn_request`] and `mxc_exec_state_aware_json`): on success box the /// [`Sandbox`] into an /// [`MxcSandbox`] handle and write it to `*out_handle`; on failure hand the /// detail to `*out_error` (when non-null) and return the status. diff --git a/src/ffi/mxc_ffi/tests/ffi.rs b/src/ffi/mxc_ffi/tests/ffi.rs index 2ea3477d8..d64fa330f 100644 --- a/src/ffi/mxc_ffi/tests/ffi.rs +++ b/src/ffi/mxc_ffi/tests/ffi.rs @@ -8,18 +8,19 @@ use std::ffi::{CStr, CString}; use std::ptr; +#[cfg(target_os = "linux")] +use mxc_ffi::mxc_spawn_request; use mxc_ffi::{ - mxc_available_backends_json, mxc_platform_support_json, mxc_run_request, mxc_run_result_free, - mxc_sandbox_stderr_closer, mxc_sandbox_stdout_closer, mxc_sandbox_warnings_json, - mxc_stream_closer_close, mxc_stream_closer_free, mxc_string_free, mxc_version, MxcRunResult, + mxc_available_backends_json, mxc_error_detail_free, mxc_platform_support_json, mxc_run_json, + mxc_run_request, mxc_run_result_free, mxc_sandbox_stderr_closer, mxc_sandbox_stdout_closer, + mxc_sandbox_warnings_json, mxc_spawn_json, mxc_stream_closer_close, mxc_stream_closer_free, + mxc_string_free, mxc_version, MxcErrorDetail, MxcRunResult, MxcSandbox, }; #[cfg(target_os = "windows")] use mxc_ffi::{ - mxc_error_detail_free, mxc_probe_request_json, mxc_probe_request_json_with_error, - mxc_probe_sandbox_request_json_with_error, MxcErrorDetail, + mxc_probe_request_json, mxc_probe_request_json_with_error, + mxc_probe_sandbox_request_json_with_error, }; -#[cfg(target_os = "linux")] -use mxc_ffi::{mxc_error_detail_free, mxc_spawn_request, MxcErrorDetail, MxcSandbox}; /// An empty, all-null result to hand to `mxc_run_request`. fn zeroed_result() -> MxcRunResult { @@ -285,6 +286,115 @@ fn extern_run_request_rejects_null_result_before_parsing() { assert_eq!(status, mxc_ffi::MXC_STATUS_NULL_ARGUMENT); } +/// Run a raw JSON request that is expected to fail, returning its status and +/// message after freeing the populated result. +fn run_json_failure(json: &str) -> (i32, String) { + let request = CString::new(json).unwrap(); + let mut out = zeroed_result(); + // SAFETY: valid C string and output storage. + let status = unsafe { mxc_run_json(request.as_ptr(), 0, &mut out) }; + assert_eq!(out.status, status); + assert!(out.stdout_utf8.is_null()); + assert!(!out.error.message_utf8.is_null()); + // SAFETY: failures populate an owned message. + let message = unsafe { CStr::from_ptr(out.error.message_utf8) } + .to_string_lossy() + .into_owned(); + // SAFETY: `out` was filled by `mxc_run_json`; failures must be freed too. + unsafe { mxc_run_result_free(&mut out) }; + (status, message) +} + +#[test] +fn extern_run_json_rejects_null_result_before_parsing() { + // Invalid UTF-8 would win if the request were parsed before the mandatory + // result pointer was checked. + let invalid_utf8 = [0xff_u8, 0]; + // SAFETY: the byte buffer is NUL-terminated and the result pointer is null. + let status = unsafe { mxc_run_json(invalid_utf8.as_ptr().cast(), 0, ptr::null_mut()) }; + + assert_eq!(status, mxc_ffi::MXC_STATUS_NULL_ARGUMENT); +} + +#[test] +fn extern_run_json_reports_null_and_invalid_utf8_requests() { + let mut out = zeroed_result(); + // SAFETY: the null request is deliberate; the output storage is valid. + let status = unsafe { mxc_run_json(ptr::null(), 0, &mut out) }; + assert_eq!(status, mxc_ffi::MXC_STATUS_NULL_ARGUMENT); + assert_eq!(out.status, status); + // SAFETY: `out` was filled by `mxc_run_json`. + unsafe { mxc_run_result_free(&mut out) }; + + let invalid_utf8 = [0xff_u8, 0]; + let mut out = zeroed_result(); + // SAFETY: NUL-terminated bytes and valid output storage. + let status = unsafe { mxc_run_json(invalid_utf8.as_ptr().cast(), 0, &mut out) }; + assert_eq!(status, mxc_ffi::MXC_STATUS_INVALID_UTF8); + assert_eq!(out.status, status); + // SAFETY: `out` was filled by `mxc_run_json`. + unsafe { mxc_run_result_free(&mut out) }; +} + +#[test] +fn extern_run_json_rejects_lifecycle_envelopes() { + let (status, message) = + run_json_failure(r#"{"version":"1.0.0","phase":"start","sandboxId":"iso:example"}"#); + + assert_eq!(status, mxc_ffi::MXC_STATUS_MALFORMED_REQUEST); + assert!(message.contains("one-shot"), "{message}"); +} + +#[test] +fn extern_run_json_rejects_unregistered_versions() { + let (status, message) = + run_json_failure(r#"{"version":"0.6.1-alpha","process":{"commandLine":"echo hello"}}"#); + + assert_eq!(status, mxc_ffi::MXC_STATUS_MALFORMED_REQUEST); + assert!( + message.contains("Unsupported contract version"), + "{message}" + ); +} + +#[test] +fn extern_spawn_json_clears_error_and_rejects_null_handle_before_parsing() { + let mut error = MxcErrorDetail { + message_utf8: ptr::dangling_mut(), + operation_utf8: ptr::dangling_mut(), + native_code_utf8: ptr::dangling_mut(), + remediation_utf8: ptr::dangling_mut(), + }; + let invalid_utf8 = [0xff_u8, 0]; + // SAFETY: the error storage is writable and holds no live detail; the null + // handle pointer is deliberate. + let status = + unsafe { mxc_spawn_json(invalid_utf8.as_ptr().cast(), 0, ptr::null_mut(), &mut error) }; + + assert_eq!(status, mxc_ffi::MXC_STATUS_NULL_ARGUMENT); + assert!(error.message_utf8.is_null()); + assert!(error.operation_utf8.is_null()); + assert!(error.native_code_utf8.is_null()); + assert!(error.remediation_utf8.is_null()); +} + +#[test] +fn extern_spawn_json_failure_returns_no_handle_and_an_owned_error() { + let request = CString::new("not json").unwrap(); + let mut handle: *mut MxcSandbox = ptr::dangling_mut(); + // SAFETY: an all-null detail is the empty shape. + let mut error: MxcErrorDetail = unsafe { std::mem::zeroed() }; + // SAFETY: valid request and output storage. + let status = unsafe { mxc_spawn_json(request.as_ptr(), 0, &mut handle, &mut error) }; + + assert_eq!(status, mxc_ffi::MXC_STATUS_MALFORMED_REQUEST); + assert!(handle.is_null()); + assert!(!error.message_utf8.is_null()); + // SAFETY: the detail was filled by `mxc_spawn_json`. + unsafe { mxc_error_detail_free(&mut error) }; + assert!(error.message_utf8.is_null()); +} + /// A real run requires a host backend; on Windows that means an elevated, /// host-prepped host (see docs/host-prep.md), so this is `#[ignore]`d. #[cfg(target_os = "windows")] diff --git a/tests/policy/README.md b/tests/policy/README.md index 8ca667512..f18c29511 100644 --- a/tests/policy/README.md +++ b/tests/policy/README.md @@ -1,5 +1,111 @@ # Cross-language policy fixtures +This directory holds shared policy fixture families: + +- `sdk-v1/` — the exact 1.0.0 documents every v1 SDK must emit for a shared + set of high-level policy invocations. See + [SDK v1 conformance fixtures](#sdk-v1-conformance-fixtures). +- The top-level `request-*.json` files — the private binding request accepted by the + deprecated `mxc_run_request` / `mxc_spawn_request` exports. They are removed + with those exports. +- The top-level `state-aware-*.json` files are exact lifecycle envelopes and + remain independent of the private binding-request cleanup. + +## SDK v1 conformance fixtures + +Each case is a pair of files with the same name: + +- `sdk-v1/input/.json` describes the invocation in SDK-neutral terms: a + `policy` in the high-level `SandboxPolicy` shape, a `containment` tagged by + `kind`, the `command`, and optional `containerName`, `workingDirectory`, + `environment`, `inheritDefaultEnv`, and `telemetry`. +- `sdk-v1/expected/.json` is the exact 1.0.0 document an SDK sends to + `mxc_run_json` / `mxc_spawn_json` for that invocation. + +`sdk-v1/invalid/.json` holds exact documents the native parser must +reject, with the expected `errorCode` and a `messageContains` fragment. + +### What is compared + +The input document is an SDK-neutral invocation description, not a native +configuration contract. The Rust test converts it to policy authoring types +and calls `build_request_with_containment`, the request constructor exported +by the Rust SDK. That constructor builds exact `1.0.0` contract values and +adapts them through shared normalization to a runtime `ExecutionRequest`. + +The matching expected document is an independently hand-authored exact +`1.0.0` JSON request. The test submits it to +`wxc_common::config_parser::load_mxc_request_from_json`, which selects the +registered exact type, applies its version-specific adapter, and normalizes +it to another `ExecutionRequest`. Comparing the two requests checks that +the reference JSON preserves the SDK invocation's intent. Only +`source_contract` diagnostic provenance is excluded from the comparison. + +Expected documents must not be generated by the builder under test: otherwise +an unintended mapping change could update both sides and hide a regression. + +### Consumers + +- **Rust** — `src/core/mxc_engine/src/policy/sdk_v1_conformance.rs` builds each + input with the Rust policy builder, parses the expected document, and + asserts both normalize to the same execution request. It also asserts every + invalid document is rejected for the recorded reason. +- **Schema** — `scripts/versioning/validate-configs.js` validates every + expected document against the registered 1.0.0 schema. +- **Node and .NET (planned migration coverage)** — their JSON-ingress + follow-ups map inputs through the high-level API and assert emitted JSON + equals the expected document. Their current one-shot paths still use the + private binding request. + +The Rust policy module includes its conformance module only under +`#[cfg(test)]`, so it is not part of the production library. It lives under +`src/` to inspect private `SandboxRequest` internals without exposing them +through the public SDK API solely for an integration test. + +### Mapping rules + +The expected documents follow these rules, which every SDK mapper applies: + +- `version` is `1.0.0`. `experimental` is never written; it is an FFI + argument. +- `containerId` is the caller's container name or, when there is none, a + fresh random identifier per request. It is never omitted: an absent + `containerId` selects a shared default container. +- `containment` is the selected wire name. Abstract `process` stays `process` + and carries no backend section; the engine resolves it per host. +- `lifecycle` is always `{ "destroyOnExit": true, "preservePolicy": P }`, + where `P` is the negation of `filesystem.clearPolicyOnExit` (default + `true`, so `P` defaults to `false`). +- `process.timeout` is always present: `timeoutMs`, or `0` for no timeout. + `cwd`, `env` (`KEY=VALUE` in caller order), and `inheritDefaultEnv` are + written only when the caller sets an environment or working directory. +- `filesystem` is always present with all three path arrays, empty when + unset. +- `network` is written only when the policy has `egress` or `ingress`; a + runtime-only network section authors no sandbox posture. `runtimeConfig` is + written when the policy has one. +- `ui` is written only when the caller supplies one: `disable` is the + negation of `allowWindows`, and `injection` is `allowInputInjection`. +- `processContainer` is written only for `processcontainer`. `leastPrivilege` + and `capabilities` are always present; `capabilities` holds only the + caller's names. Network capabilities such as `internetClient` are derived by + the backend from the network policy. `learningMode` is written only when + true, and `network.allowedProxyPeer` only when set. +- `seatbelt`, `lxc`, and `wslc` are written only for their own containment. + `wslc.image` and `wslc.gpu` are always present; `portMappings` is written + only when non-empty, each with `"protocol": "tcp"`. +- `telemetry` is written only when the caller sets the per-invocation + opt-in. + +### Adding a case + +Write both files by hand. Run +`cargo test -p mxc_engine -- sdk_v1_conformance` and +`node scripts/versioning/validate-configs.js`, then update each SDK's conformance +test so it covers the new input. + +## Binding request fixtures + Hand-authored JSON request fixtures asserted against by **both** language bindings. They pin the co-versioned binding request contract — the `RequestSpec` / `SandboxRequest` wire shape — so the Rust and C# models cannot @@ -13,7 +119,7 @@ drift apart silently. | `state-aware-wslc-provision.json` | State-aware WSLC `provision` envelope | | `state-aware-wslc-exec.json` | State-aware WSLC `exec` envelope | -## Consumers +### Consumers - **Rust** — `src/ffi/mxc_ffi/src/request.rs` and `src/ffi/mxc_ffi/src/state_aware.rs` pull each file in with `include_str!` and assert the native contract accepts it. @@ -25,7 +131,7 @@ They live here rather than under either SDK because neither owns them: a Rust crate reaching into `sdk/dotnet/` for test data inverts the dependency, and reorganizing one SDK would break the other's tests. -## These are written by hand, on purpose +### These are written by hand, on purpose Do **not** generate them from the Rust structs or the C# POCOs. Their value is that they are an *independent* statement of the expected wire shape. Deriving @@ -36,11 +142,16 @@ test would still pass. When the request contract changes intentionally, edit these files by hand and let both test suites confirm the change is what you meant. -## Not config files +### Not config files -These are **binding request** documents (`{ policy, command, containment, … }`), +The `request-*.json` files are **binding request** documents +(`{ policy, command, containment, ... }`), not exact configuration documents. They are deliberately outside `tests/configs/` and `tests/examples/`, where `scripts/versioning/validate-configs.js` selects each document's exact registered schema from its `version`; these would fail because they describe a different contract. + +The `state-aware-*.json` files instead carry exact lifecycle envelopes with a +`version`. They are consumed by lifecycle tests and are not removed with the +private one-shot binding-request fixtures. diff --git a/tests/policy/sdk-v1/expected/bubblewrap.json b/tests/policy/sdk-v1/expected/bubblewrap.json new file mode 100644 index 000000000..540f5d8f2 --- /dev/null +++ b/tests/policy/sdk-v1/expected/bubblewrap.json @@ -0,0 +1,18 @@ +{ + "version": "1.0.0", + "containerId": "golden-bubblewrap", + "containment": "bubblewrap", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false + }, + "process": { + "commandLine": "echo golden", + "timeout": 0 + }, + "filesystem": { + "readwritePaths": [], + "readonlyPaths": [], + "deniedPaths": [] + } +} diff --git a/tests/policy/sdk-v1/expected/invocation-options.json b/tests/policy/sdk-v1/expected/invocation-options.json new file mode 100644 index 000000000..874d32bee --- /dev/null +++ b/tests/policy/sdk-v1/expected/invocation-options.json @@ -0,0 +1,27 @@ +{ + "version": "1.0.0", + "containerId": "golden-invocation-options", + "containment": "process", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false + }, + "process": { + "commandLine": "echo golden", + "cwd": "work", + "env": [ + "A=1", + "B=2" + ], + "inheritDefaultEnv": true, + "timeout": 0 + }, + "filesystem": { + "readwritePaths": [], + "readonlyPaths": [], + "deniedPaths": [] + }, + "telemetry": { + "enabled": true + } +} diff --git a/tests/policy/sdk-v1/expected/isolation-session.json b/tests/policy/sdk-v1/expected/isolation-session.json new file mode 100644 index 000000000..1fb61b6ad --- /dev/null +++ b/tests/policy/sdk-v1/expected/isolation-session.json @@ -0,0 +1,27 @@ +{ + "version": "1.0.0", + "containerId": "golden-isolation-session", + "containment": "isolation_session", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false + }, + "process": { + "commandLine": "echo golden", + "timeout": 0 + }, + "filesystem": { + "readwritePaths": [], + "readonlyPaths": [], + "deniedPaths": [] + }, + "network": { + "egress": { + "default": "allow" + }, + "ingress": { + "default": "allow", + "hostLoopback": "allow" + } + } +} diff --git a/tests/policy/sdk-v1/expected/lxc.json b/tests/policy/sdk-v1/expected/lxc.json new file mode 100644 index 000000000..4ad35523f --- /dev/null +++ b/tests/policy/sdk-v1/expected/lxc.json @@ -0,0 +1,22 @@ +{ + "version": "1.0.0", + "containerId": "golden-lxc", + "containment": "lxc", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false + }, + "process": { + "commandLine": "echo golden", + "timeout": 0 + }, + "filesystem": { + "readwritePaths": [], + "readonlyPaths": [], + "deniedPaths": [] + }, + "lxc": { + "distribution": "ubuntu", + "release": "jammy" + } +} diff --git a/tests/policy/sdk-v1/expected/process-directional-network.json b/tests/policy/sdk-v1/expected/process-directional-network.json new file mode 100644 index 000000000..9377cf4be --- /dev/null +++ b/tests/policy/sdk-v1/expected/process-directional-network.json @@ -0,0 +1,59 @@ +{ + "version": "1.0.0", + "containerId": "golden-process-directional-network", + "containment": "process", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false + }, + "process": { + "commandLine": "echo golden", + "timeout": 0 + }, + "filesystem": { + "readwritePaths": [], + "readonlyPaths": [], + "deniedPaths": [] + }, + "network": { + "egress": { + "default": "deny", + "allow": [ + { + "to": [ + { + "cidr": "10.0.0.0/8", + "except": [ + "10.1.0.0/16" + ] + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + }, + { + "protocol": "udp", + "port": 5000, + "endPort": 5100 + } + ] + } + ], + "deny": [ + { + "to": [ + { + "cidr": "10.2.0.0/16" + } + ] + } + ] + }, + "ingress": { + "default": "allow", + "hostLoopback": "deny" + } + } +} diff --git a/tests/policy/sdk-v1/expected/process-filesystem-ui-timeout.json b/tests/policy/sdk-v1/expected/process-filesystem-ui-timeout.json new file mode 100644 index 000000000..6e67ae3ed --- /dev/null +++ b/tests/policy/sdk-v1/expected/process-filesystem-ui-timeout.json @@ -0,0 +1,29 @@ +{ + "version": "1.0.0", + "containerId": "golden-process-filesystem-ui-timeout", + "containment": "process", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": true + }, + "process": { + "commandLine": "echo golden", + "timeout": 30000 + }, + "filesystem": { + "readwritePaths": [ + "work" + ], + "readonlyPaths": [ + "tools" + ], + "deniedPaths": [ + "secrets" + ] + }, + "ui": { + "disable": false, + "clipboard": "read", + "injection": false + } +} diff --git a/tests/policy/sdk-v1/expected/process-minimal.json b/tests/policy/sdk-v1/expected/process-minimal.json new file mode 100644 index 000000000..91e8db63d --- /dev/null +++ b/tests/policy/sdk-v1/expected/process-minimal.json @@ -0,0 +1,18 @@ +{ + "version": "1.0.0", + "containerId": "golden-process-minimal", + "containment": "process", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false + }, + "process": { + "commandLine": "echo golden", + "timeout": 0 + }, + "filesystem": { + "readwritePaths": [], + "readonlyPaths": [], + "deniedPaths": [] + } +} diff --git a/tests/policy/sdk-v1/expected/processcontainer-network-proxy.json b/tests/policy/sdk-v1/expected/processcontainer-network-proxy.json new file mode 100644 index 000000000..e84c4ea3e --- /dev/null +++ b/tests/policy/sdk-v1/expected/processcontainer-network-proxy.json @@ -0,0 +1,37 @@ +{ + "version": "1.0.0", + "containerId": "golden-processcontainer-network-proxy", + "containment": "processcontainer", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false + }, + "process": { + "commandLine": "echo golden", + "timeout": 0 + }, + "filesystem": { + "readwritePaths": [], + "readonlyPaths": [], + "deniedPaths": [] + }, + "network": { + "egress": { + "default": "deny" + }, + "ingress": { + "default": "allow", + "hostLoopback": "deny" + } + }, + "processContainer": { + "leastPrivilege": false, + "capabilities": [], + "network": { + "allowedProxyPeer": "Contoso.Proxy" + } + }, + "runtimeConfig": { + "networkProxy": "http://127.0.0.1:8080" + } +} diff --git a/tests/policy/sdk-v1/expected/processcontainer-options.json b/tests/policy/sdk-v1/expected/processcontainer-options.json new file mode 100644 index 000000000..b2ba2db2e --- /dev/null +++ b/tests/policy/sdk-v1/expected/processcontainer-options.json @@ -0,0 +1,25 @@ +{ + "version": "1.0.0", + "containerId": "golden-processcontainer-options", + "containment": "processcontainer", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false + }, + "process": { + "commandLine": "echo golden", + "timeout": 0 + }, + "filesystem": { + "readwritePaths": [], + "readonlyPaths": [], + "deniedPaths": [] + }, + "processContainer": { + "leastPrivilege": true, + "learningMode": true, + "capabilities": [ + "registryRead" + ] + } +} diff --git a/tests/policy/sdk-v1/expected/seatbelt-options.json b/tests/policy/sdk-v1/expected/seatbelt-options.json new file mode 100644 index 000000000..375dd5f19 --- /dev/null +++ b/tests/policy/sdk-v1/expected/seatbelt-options.json @@ -0,0 +1,26 @@ +{ + "version": "1.0.0", + "containerId": "golden-seatbelt-options", + "containment": "seatbelt", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false + }, + "process": { + "commandLine": "echo golden", + "timeout": 0 + }, + "filesystem": { + "readwritePaths": [], + "readonlyPaths": [], + "deniedPaths": [] + }, + "seatbelt": { + "guiAccess": true, + "nestedPty": false, + "keychainAccess": true, + "extraMachLookups": [ + "com.example.service" + ] + } +} diff --git a/tests/policy/sdk-v1/expected/wslc.json b/tests/policy/sdk-v1/expected/wslc.json new file mode 100644 index 000000000..b2b7ae976 --- /dev/null +++ b/tests/policy/sdk-v1/expected/wslc.json @@ -0,0 +1,31 @@ +{ + "version": "1.0.0", + "containerId": "golden-wslc", + "containment": "wslc", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false + }, + "process": { + "commandLine": "echo golden", + "timeout": 0 + }, + "filesystem": { + "readwritePaths": [], + "readonlyPaths": [], + "deniedPaths": [] + }, + "wslc": { + "image": "python:3.12", + "cpuCount": 2, + "memoryMb": 1024, + "gpu": false, + "portMappings": [ + { + "windowsPort": 8080, + "containerPort": 80, + "protocol": "tcp" + } + ] + } +} diff --git a/tests/policy/sdk-v1/input/bubblewrap.json b/tests/policy/sdk-v1/input/bubblewrap.json new file mode 100644 index 000000000..0503db555 --- /dev/null +++ b/tests/policy/sdk-v1/input/bubblewrap.json @@ -0,0 +1,9 @@ +{ + "description": "Bubblewrap containment.", + "policy": {}, + "containment": { + "kind": "bubblewrap" + }, + "command": "echo golden", + "containerName": "golden-bubblewrap" +} diff --git a/tests/policy/sdk-v1/input/invocation-options.json b/tests/policy/sdk-v1/input/invocation-options.json new file mode 100644 index 000000000..25ced9808 --- /dev/null +++ b/tests/policy/sdk-v1/input/invocation-options.json @@ -0,0 +1,16 @@ +{ + "description": "Working directory, layered environment, and telemetry opt-in.", + "policy": {}, + "containment": { + "kind": "process" + }, + "command": "echo golden", + "containerName": "golden-invocation-options", + "workingDirectory": "work", + "environment": { + "A": "1", + "B": "2" + }, + "inheritDefaultEnv": true, + "telemetry": true +} diff --git a/tests/policy/sdk-v1/input/isolation-session.json b/tests/policy/sdk-v1/input/isolation-session.json new file mode 100644 index 000000000..1c4995dcf --- /dev/null +++ b/tests/policy/sdk-v1/input/isolation-session.json @@ -0,0 +1,19 @@ +{ + "description": "IsolationSession requires the all-allow directional network.", + "policy": { + "network": { + "egress": { + "default": "allow" + }, + "ingress": { + "default": "allow", + "hostLoopback": "allow" + } + } + }, + "containment": { + "kind": "isolationSession" + }, + "command": "echo golden", + "containerName": "golden-isolation-session" +} diff --git a/tests/policy/sdk-v1/input/lxc.json b/tests/policy/sdk-v1/input/lxc.json new file mode 100644 index 000000000..dc5061bec --- /dev/null +++ b/tests/policy/sdk-v1/input/lxc.json @@ -0,0 +1,11 @@ +{ + "description": "LXC with an explicit distribution.", + "policy": {}, + "containment": { + "kind": "lxc", + "distribution": "ubuntu", + "release": "jammy" + }, + "command": "echo golden", + "containerName": "golden-lxc" +} diff --git a/tests/policy/sdk-v1/input/process-directional-network.json b/tests/policy/sdk-v1/input/process-directional-network.json new file mode 100644 index 000000000..83e3b0362 --- /dev/null +++ b/tests/policy/sdk-v1/input/process-directional-network.json @@ -0,0 +1,51 @@ +{ + "description": "Directional egress rules and ingress defaults.", + "policy": { + "network": { + "egress": { + "default": "deny", + "allow": [ + { + "to": [ + { + "cidr": "10.0.0.0/8", + "except": [ + "10.1.0.0/16" + ] + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + }, + { + "protocol": "udp", + "port": 5000, + "endPort": 5100 + } + ] + } + ], + "deny": [ + { + "to": [ + { + "cidr": "10.2.0.0/16" + } + ] + } + ] + }, + "ingress": { + "default": "allow", + "hostLoopback": "deny" + } + } + }, + "containment": { + "kind": "process" + }, + "command": "echo golden", + "containerName": "golden-process-directional-network" +} diff --git a/tests/policy/sdk-v1/input/process-filesystem-ui-timeout.json b/tests/policy/sdk-v1/input/process-filesystem-ui-timeout.json new file mode 100644 index 000000000..a2038d7f1 --- /dev/null +++ b/tests/policy/sdk-v1/input/process-filesystem-ui-timeout.json @@ -0,0 +1,28 @@ +{ + "description": "Filesystem lists, retained policy, cross-platform UI, and a timeout.", + "policy": { + "filesystem": { + "readwritePaths": [ + "work" + ], + "readonlyPaths": [ + "tools" + ], + "deniedPaths": [ + "secrets" + ], + "clearPolicyOnExit": false + }, + "ui": { + "allowWindows": true, + "clipboard": "read", + "allowInputInjection": false + }, + "timeoutMs": 30000 + }, + "containment": { + "kind": "process" + }, + "command": "echo golden", + "containerName": "golden-process-filesystem-ui-timeout" +} diff --git a/tests/policy/sdk-v1/input/process-minimal.json b/tests/policy/sdk-v1/input/process-minimal.json new file mode 100644 index 000000000..b104cff17 --- /dev/null +++ b/tests/policy/sdk-v1/input/process-minimal.json @@ -0,0 +1,9 @@ +{ + "description": "Empty policy on the host's native process containment.", + "policy": {}, + "containment": { + "kind": "process" + }, + "command": "echo golden", + "containerName": "golden-process-minimal" +} diff --git a/tests/policy/sdk-v1/input/processcontainer-network-proxy.json b/tests/policy/sdk-v1/input/processcontainer-network-proxy.json new file mode 100644 index 000000000..4c5b178e6 --- /dev/null +++ b/tests/policy/sdk-v1/input/processcontainer-network-proxy.json @@ -0,0 +1,23 @@ +{ + "description": "A ProcessContainer runtime proxy reached through an allowed proxy peer.", + "policy": { + "network": { + "egress": { + "default": "deny" + }, + "ingress": { + "default": "allow", + "hostLoopback": "deny" + }, + "runtimeConfig": { + "networkProxy": "http://127.0.0.1:8080" + } + } + }, + "containment": { + "kind": "processContainer", + "allowedProxyPeer": "Contoso.Proxy" + }, + "command": "echo golden", + "containerName": "golden-processcontainer-network-proxy" +} diff --git a/tests/policy/sdk-v1/input/processcontainer-options.json b/tests/policy/sdk-v1/input/processcontainer-options.json new file mode 100644 index 000000000..ef03affe0 --- /dev/null +++ b/tests/policy/sdk-v1/input/processcontainer-options.json @@ -0,0 +1,14 @@ +{ + "description": "Explicit ProcessContainer settings.", + "policy": {}, + "containment": { + "kind": "processContainer", + "leastPrivilege": true, + "learningMode": true, + "capabilities": [ + "registryRead" + ] + }, + "command": "echo golden", + "containerName": "golden-processcontainer-options" +} diff --git a/tests/policy/sdk-v1/input/seatbelt-options.json b/tests/policy/sdk-v1/input/seatbelt-options.json new file mode 100644 index 000000000..330ba0af5 --- /dev/null +++ b/tests/policy/sdk-v1/input/seatbelt-options.json @@ -0,0 +1,15 @@ +{ + "description": "Explicit Seatbelt settings.", + "policy": {}, + "containment": { + "kind": "seatbelt", + "guiAccess": true, + "nestedPty": false, + "keychainAccess": true, + "extraMachLookups": [ + "com.example.service" + ] + }, + "command": "echo golden", + "containerName": "golden-seatbelt-options" +} diff --git a/tests/policy/sdk-v1/input/wslc.json b/tests/policy/sdk-v1/input/wslc.json new file mode 100644 index 000000000..529ccf324 --- /dev/null +++ b/tests/policy/sdk-v1/input/wslc.json @@ -0,0 +1,19 @@ +{ + "description": "WSLc image, resources, and a TCP port mapping.", + "policy": {}, + "containment": { + "kind": "wslc", + "image": "python:3.12", + "cpuCount": 2, + "memoryMb": 1024, + "gpu": false, + "portMappings": [ + [ + 8080, + 80 + ] + ] + }, + "command": "echo golden", + "containerName": "golden-wslc" +} diff --git a/tests/policy/sdk-v1/invalid/blank-proxy-peer.json b/tests/policy/sdk-v1/invalid/blank-proxy-peer.json new file mode 100644 index 000000000..505f0f8cb --- /dev/null +++ b/tests/policy/sdk-v1/invalid/blank-proxy-peer.json @@ -0,0 +1,17 @@ +{ + "description": "A supplied ProcessContainer proxy peer must not be blank.", + "errorCode": "malformed_request", + "messageContains": "processContainer.network.allowedProxyPeer must not be blank", + "document": { + "version": "1.0.0", + "containment": "processcontainer", + "process": { + "commandLine": "echo golden" + }, + "processContainer": { + "network": { + "allowedProxyPeer": " \t\r\n" + } + } + } +} diff --git a/tests/policy/sdk-v1/invalid/comma-capability.json b/tests/policy/sdk-v1/invalid/comma-capability.json new file mode 100644 index 000000000..ea11e6b4f --- /dev/null +++ b/tests/policy/sdk-v1/invalid/comma-capability.json @@ -0,0 +1,17 @@ +{ + "description": "Each capability entry names one capability.", + "errorCode": "malformed_request", + "messageContains": "must not contain a comma", + "document": { + "version": "1.0.0", + "containment": "processcontainer", + "process": { + "commandLine": "echo golden" + }, + "processContainer": { + "capabilities": [ + "internetClient,registryRead" + ] + } + } +} diff --git a/tests/policy/sdk-v1/invalid/isolation-session-without-network.json b/tests/policy/sdk-v1/invalid/isolation-session-without-network.json new file mode 100644 index 000000000..975e39e51 --- /dev/null +++ b/tests/policy/sdk-v1/invalid/isolation-session-without-network.json @@ -0,0 +1,12 @@ +{ + "description": "IsolationSession requires the all-allow directional network.", + "errorCode": "malformed_request", + "messageContains": "IsolationSession requires an explicit network policy", + "document": { + "version": "1.0.0", + "containment": "isolation_session", + "process": { + "commandLine": "echo golden" + } + } +} diff --git a/tests/policy/sdk-v1/invalid/process-with-wslc-section.json b/tests/policy/sdk-v1/invalid/process-with-wslc-section.json new file mode 100644 index 000000000..265bd7a00 --- /dev/null +++ b/tests/policy/sdk-v1/invalid/process-with-wslc-section.json @@ -0,0 +1,15 @@ +{ + "description": "A backend section must match the selected containment.", + "errorCode": "malformed_request", + "messageContains": "unrelated backend section(s): wslc", + "document": { + "version": "1.0.0", + "containment": "process", + "process": { + "commandLine": "echo golden" + }, + "wslc": { + "image": "alpine:latest" + } + } +} diff --git a/tests/policy/sdk-v1/invalid/processcontainer-with-seatbelt-section.json b/tests/policy/sdk-v1/invalid/processcontainer-with-seatbelt-section.json new file mode 100644 index 000000000..ea17cb2b6 --- /dev/null +++ b/tests/policy/sdk-v1/invalid/processcontainer-with-seatbelt-section.json @@ -0,0 +1,15 @@ +{ + "description": "A backend section must match the selected containment.", + "errorCode": "malformed_request", + "messageContains": "unrelated backend section(s): seatbelt", + "document": { + "version": "1.0.0", + "containment": "processcontainer", + "process": { + "commandLine": "echo golden" + }, + "seatbelt": { + "guiAccess": true + } + } +} diff --git a/tests/policy/sdk-v1/invalid/unknown-field.json b/tests/policy/sdk-v1/invalid/unknown-field.json new file mode 100644 index 000000000..55a5aa507 --- /dev/null +++ b/tests/policy/sdk-v1/invalid/unknown-field.json @@ -0,0 +1,12 @@ +{ + "description": "Exact contracts are closed.", + "errorCode": "malformed_request", + "messageContains": "unknown field `experimental`", + "document": { + "version": "1.0.0", + "process": { + "commandLine": "echo golden" + }, + "experimental": true + } +}