From b0ac04a2d7110a4a6b1d4909c3dff67b03227820 Mon Sep 17 00:00:00 2001 From: Gudge Date: Wed, 30 Sep 2026 09:33:41 -0700 Subject: [PATCH 1/4] Make exact versioned JSON the only FFI configuration ingress This PR makes exact versioned JSON the only way sandbox configuration crosses the native mxc_ffi boundary. Non-configuration controls, such as the experimental opt-in and dry-run, stay typed i32 arguments and are never read from JSON. Details * Add mxc_run_json and mxc_spawn_json, backed by mxc_sdk::run_json and spawn_sandbox_json, for exact-version one-shot documents. * Rename the lifecycle exports to mxc_run_state_aware_json, mxc_exec_state_aware_json, and mxc_exec_state_aware_attached_json, with no aliases, and move every .NET, Node, and example caller. * Replace the per-backend experimental checks with one rule: the opt-in only permits an experimental backend (MicroVM, Hyperlight, Windows Sandbox) and is ignored for production backends. A missing opt-in is backend_unavailable on every one-shot and lifecycle path. * Add tests/policy/sdk-v1 goldens pairing high-level policy invocations with the exact 1.0.0 document every v1 SDK must emit, plus invalid documents, and align the Rust builder with them. * Document the JSON-only ingress rule and deprecate mxc_run_request, mxc_spawn_request, and the private binding request. * Remove Deserialize and Serialize from the Rust SDK policy types; the deprecated binding request owns private copies until it is removed. Tests * From src, these format, compile, lint and unit-test commands passed: cargo fmt -p mxc_ffi -p mxc_engine -p mxc-sdk -p wxc_common -- --check cargo check -p mxc_ffi -p mxc_engine -p mxc-sdk -p wxc_common --all-targets cargo clippy -p mxc_ffi -p mxc_engine -p mxc-sdk -p wxc_common --all-targets -- -D warnings cargo test -p mxc_ffi -p mxc_engine -p mxc-sdk -p wxc_common * cargo check -p mxc_ffi --all-targets --features isolation_session,wslc passed on Windows. * Node: npm run build passed; npm test passed against freshly built mxc_ffi.dll (387 passed, 20 skipped). * .NET: dotnet test --solution Microsoft.Mxc.Sdk.slnx passed (266 passed, 27 host-dependent tests skipped). * node scripts\check-dotnet-bindings-codegen.js passed (40 entry points); node scripts\versioning\validate-configs.js passed (414 exact configs). * Linux/macOS execution and host-dependent backend E2E were not run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2b92ee5b-30e2-4c0c-8189-df64fc215078 Generated-with: gpt-6.1-sol --- docs/isolation-session/state-aware-rust.md | 6 +- .../mxc-state-aware-sandbox-api.md | 4 +- docs/versioning.md | 18 + scripts/check-dotnet-bindings-codegen.js | 13 +- scripts/versioning/validate-configs.js | 8 +- .../V1/MxcLifecycleTests.cs | 2 +- .../Microsoft.Mxc.Sdk/V1/MxcLifecycle.cs | 6 +- sdk/node/src/bindings/state-aware.ts | 2 +- sdk/node/src/bindings/streaming.ts | 2 +- sdk/node/tests/unit/state-aware.test.ts | 2 +- src/core/mxc-sdk/README.md | 6 - src/core/mxc-sdk/src/lib.rs | 42 +- .../src/configs/process_container.rs | 32 +- src/core/mxc_engine/src/experimental.rs | 71 +++ src/core/mxc_engine/src/lib.rs | 45 +- src/core/mxc_engine/src/policy.rs | 58 +- src/core/mxc_engine/src/policy/exact/mod.rs | 36 +- src/core/mxc_engine/src/policy/exact/v1_0.rs | 35 +- src/core/mxc_engine/src/policy/network.rs | 68 +-- .../mxc_engine/src/policy/sdk_v1_goldens.rs | 516 ++++++++++++++++++ src/core/mxc_engine/src/run.rs | 78 ++- src/core/mxc_engine/src/state_aware.rs | 41 +- src/core/wxc_common/src/models.rs | 18 + .../mxc_ffi/examples/attached_console_ffi.rs | 8 +- src/ffi/mxc_ffi/src/lib.rs | 98 +++- src/ffi/mxc_ffi/src/request.rs | 291 +++++++++- src/ffi/mxc_ffi/src/state_aware.rs | 112 ++-- src/ffi/mxc_ffi/src/streaming.rs | 81 ++- src/ffi/mxc_ffi/tests/ffi.rs | 120 +++- tests/policy/README.md | 83 ++- tests/policy/sdk-v1/expected/bubblewrap.json | 18 + .../sdk-v1/expected/invocation-options.json | 27 + .../sdk-v1/expected/isolation-session.json | 27 + tests/policy/sdk-v1/expected/lxc.json | 22 + .../expected/process-directional-network.json | 59 ++ .../process-filesystem-ui-timeout.json | 29 + .../sdk-v1/expected/process-minimal.json | 18 + .../processcontainer-network-proxy.json | 37 ++ .../expected/processcontainer-options.json | 25 + .../sdk-v1/expected/seatbelt-options.json | 26 + tests/policy/sdk-v1/expected/wslc.json | 31 ++ tests/policy/sdk-v1/input/bubblewrap.json | 9 + .../sdk-v1/input/invocation-options.json | 16 + .../sdk-v1/input/isolation-session.json | 19 + tests/policy/sdk-v1/input/lxc.json | 11 + .../input/process-directional-network.json | 51 ++ .../input/process-filesystem-ui-timeout.json | 28 + .../policy/sdk-v1/input/process-minimal.json | 9 + .../input/processcontainer-network-proxy.json | 23 + .../input/processcontainer-options.json | 14 + .../policy/sdk-v1/input/seatbelt-options.json | 15 + tests/policy/sdk-v1/input/wslc.json | 19 + .../sdk-v1/invalid/comma-capability.json | 17 + .../isolation-session-without-network.json | 12 + .../invalid/process-with-wslc-section.json | 15 + ...rocesscontainer-with-seatbelt-section.json | 15 + .../policy/sdk-v1/invalid/unknown-field.json | 12 + 57 files changed, 2149 insertions(+), 357 deletions(-) create mode 100644 src/core/mxc_engine/src/experimental.rs create mode 100644 src/core/mxc_engine/src/policy/sdk_v1_goldens.rs create mode 100644 tests/policy/sdk-v1/expected/bubblewrap.json create mode 100644 tests/policy/sdk-v1/expected/invocation-options.json create mode 100644 tests/policy/sdk-v1/expected/isolation-session.json create mode 100644 tests/policy/sdk-v1/expected/lxc.json create mode 100644 tests/policy/sdk-v1/expected/process-directional-network.json create mode 100644 tests/policy/sdk-v1/expected/process-filesystem-ui-timeout.json create mode 100644 tests/policy/sdk-v1/expected/process-minimal.json create mode 100644 tests/policy/sdk-v1/expected/processcontainer-network-proxy.json create mode 100644 tests/policy/sdk-v1/expected/processcontainer-options.json create mode 100644 tests/policy/sdk-v1/expected/seatbelt-options.json create mode 100644 tests/policy/sdk-v1/expected/wslc.json create mode 100644 tests/policy/sdk-v1/input/bubblewrap.json create mode 100644 tests/policy/sdk-v1/input/invocation-options.json create mode 100644 tests/policy/sdk-v1/input/isolation-session.json create mode 100644 tests/policy/sdk-v1/input/lxc.json create mode 100644 tests/policy/sdk-v1/input/process-directional-network.json create mode 100644 tests/policy/sdk-v1/input/process-filesystem-ui-timeout.json create mode 100644 tests/policy/sdk-v1/input/process-minimal.json create mode 100644 tests/policy/sdk-v1/input/processcontainer-network-proxy.json create mode 100644 tests/policy/sdk-v1/input/processcontainer-options.json create mode 100644 tests/policy/sdk-v1/input/seatbelt-options.json create mode 100644 tests/policy/sdk-v1/input/wslc.json create mode 100644 tests/policy/sdk-v1/invalid/comma-capability.json create mode 100644 tests/policy/sdk-v1/invalid/isolation-session-without-network.json create mode 100644 tests/policy/sdk-v1/invalid/process-with-wslc-section.json create mode 100644 tests/policy/sdk-v1/invalid/processcontainer-with-seatbelt-section.json create mode 100644 tests/policy/sdk-v1/invalid/unknown-field.json 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/versioning.md b/docs/versioning.md index f2d0f9baa..d33851f83 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -293,6 +293,19 @@ baselines are captured when the v1.0 SDK surface is established rather than through empty placeholder descriptors. +### Native ingress + +Language bindings pass configuration to the native library only as exact +versioned JSON. A high-level SDK maps its policy types to the contract named +by its `sdkMajorTargets` entry, stamps that `version`, and calls +`mxc_run_json`, `mxc_spawn_json`, or the state-aware JSON exports; raw JSON +APIs pass caller-authored documents through unchanged. The native side +parses each document with its declared contract, so every surface shares one +parser and one normalization path. Controls that are not configuration, such +as the experimental opt-in, are typed FFI arguments and never JSON fields. +The shared fixtures in `tests/policy/sdk-v1/` pin the document each SDK emits +for a given high-level policy. + ### Experimental Flag The experimental flag must be supported at every layer of the stack: @@ -314,6 +327,11 @@ regardless of the flag; parsing is flag-independent. The `--experimental` flag o parsed features that still require authorization — no error, those features are just not applied +Selecting an experimental backend (MicroVM, Hyperlight, or Windows Sandbox) is +the exception: without the flag, the engine refuses the request with +`backend_unavailable` on every one-shot and state-aware entry point. The flag +has no effect on the choice of a production backend. + **2. SDK:** policy APIs come from `@microsoft/mxc-sdk/v1`; raw config spawning comes from `@microsoft/mxc-sdk`. ```typescript 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/configs/process_container.rs b/src/core/mxc_engine/src/configs/process_container.rs index 73c662964..ec9a40325 100644 --- a/src/core/mxc_engine/src/configs/process_container.rs +++ b/src/core/mxc_engine/src/configs/process_container.rs @@ -4,8 +4,7 @@ //! ProcessContainer-specific configuration types and wire mapping. /// How denial capture handles ungranted access checks. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Deserialize)] -#[serde(rename_all = "kebab-case")] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] #[non_exhaustive] pub enum CaptureDenialsMode { /// Keep the access denied and record the denial. @@ -21,8 +20,7 @@ pub enum CaptureDenialsMode { /// ungranted access attempts and writes a JSON denials document, reported /// through /// [`SandboxOutputMetadata::capture_denials`](wxc_common::models::SandboxOutputMetadata). -#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize)] -#[serde(rename_all = "camelCase", default)] +#[derive(Debug, Clone, Default, PartialEq, Eq)] #[non_exhaustive] pub struct CaptureDenials { /// How each ungranted access check is handled while it is recorded. @@ -315,8 +313,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 +333,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..5ff6cc478 --- /dev/null +++ b/src/core/mxc_engine/src/experimental.rs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Runtime authorization for experimental backends. +//! +//! Backends are either production or experimental +//! ([`ContainmentBackend::is_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; + +/// 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> { + if backend.is_experimental() && !experimental_enabled { + return Err(MxcError::backend_unavailable(format!( + "the '{}' backend is experimental; enable experimental features to use it", + 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..5bd7146e9 100644 --- a/src/core/mxc_engine/src/lib.rs +++ b/src/core/mxc_engine/src/lib.rs @@ -32,6 +32,7 @@ pub mod configs; mod dispatch; mod error; +mod experimental; #[cfg(target_os = "windows")] mod guarded_capture; mod platform; @@ -84,8 +85,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 @@ -113,22 +116,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..12a789bf0 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_goldens; use std::borrow::Cow; use std::collections::HashSet; @@ -426,8 +428,7 @@ pub fn temporary_files_policy(env: Option<&[(String, String)]>) -> FilesystemPol /// Clipboard access level, mirroring the SDK `ClipboardPolicy` /// (`"none" | "read" | "write" | "all"`). -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)] -#[serde(rename_all = "camelCase")] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum ClipboardPolicy { /// No clipboard access. #[default] @@ -441,8 +442,7 @@ pub enum ClipboardPolicy { } /// Filesystem section of a [`SandboxPolicy`]. -#[derive(Debug, Clone, Default, serde::Deserialize)] -#[serde(rename_all = "camelCase", default)] +#[derive(Debug, Clone, Default)] pub struct FilesystemSection { pub readwrite_paths: Vec, pub readonly_paths: Vec, @@ -452,8 +452,7 @@ pub struct FilesystemSection { } /// UI section of a [`SandboxPolicy`]. All flags default to denied. -#[derive(Debug, Clone, Default, serde::Deserialize)] -#[serde(rename_all = "camelCase", default)] +#[derive(Debug, Clone, Default)] pub struct UiSection { pub allow_windows: bool, pub clipboard: ClipboardPolicy, @@ -571,18 +570,13 @@ impl Default for WslcSection { /// instrumentation rather than a sandbox restriction, matching the global /// sandbox-policy design. Build the request first, then use /// [`SandboxRequest::set_telemetry_opt_in`] to opt that invocation in. -#[derive(Debug, Clone, Default, serde::Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[derive(Debug, Clone, Default)] #[non_exhaustive] pub struct SandboxPolicy { - #[serde(default)] pub filesystem: Option, - #[serde(default)] pub network: Option, - #[serde(default)] pub ui: Option, /// Execution timeout in milliseconds (`None` = no timeout). - #[serde(default)] pub timeout_ms: Option, } @@ -844,14 +838,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 +918,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 +936,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 +1225,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 +1324,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..fc0f5e335 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) => { @@ -56,7 +56,6 @@ fn validate_common(containment: &Containment) -> Result<(), MxcError> { 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 +63,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 +74,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) 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..cee674024 100644 --- a/src/core/mxc_engine/src/policy/network.rs +++ b/src/core/mxc_engine/src/policy/network.rs @@ -3,54 +3,23 @@ //! 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 /// configuration remains available through the raw configuration parser. -#[derive(Debug, Clone, Default, serde::Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[derive(Debug, Clone, Default)] #[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, /// Inbound and host-loopback network policy. - #[serde(default)] pub ingress: Option, /// Runtime values supplied separately from sandbox policy. - #[serde(default)] pub runtime_config: Option, } /// Allow or deny a network action. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "lowercase")] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] #[non_exhaustive] pub enum NetworkAction { Allow, @@ -59,8 +28,7 @@ pub enum NetworkAction { } /// Transport protocol selector. -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "lowercase")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] pub enum NetworkProtocol { Tcp, @@ -70,12 +38,10 @@ pub enum NetworkProtocol { } /// CIDR network peer. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "camelCase")] +#[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] pub struct NetworkPeerSection { pub cidr: String, - #[serde(skip_serializing_if = "Option::is_none")] pub except: Option>, } @@ -90,61 +56,45 @@ impl NetworkPeerSection { } /// Protocol and destination-port selector. -#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "camelCase")] +#[derive(Debug, Clone, Default, PartialEq, Eq)] #[non_exhaustive] pub struct NetworkPortSection { - #[serde(skip_serializing_if = "Option::is_none")] pub protocol: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub port: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub end_port: Option, } /// Outbound network rule. -#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "camelCase")] +#[derive(Debug, Clone, Default, PartialEq, Eq)] #[non_exhaustive] pub struct NetworkRuleSection { - #[serde(skip_serializing_if = "Option::is_none")] pub to: Option>, - #[serde(skip_serializing_if = "Option::is_none")] pub ports: Option>, } /// Outbound network policy. -#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "camelCase")] +#[derive(Debug, Clone, Default, PartialEq, Eq)] #[non_exhaustive] pub struct NetworkEgressSection { - #[serde(skip_serializing_if = "Option::is_none")] pub default: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub allow: Option>, - #[serde(skip_serializing_if = "Option::is_none")] pub deny: Option>, } /// Inbound and host-loopback network policy. -#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "camelCase")] +#[derive(Debug, Clone, Default, PartialEq, Eq)] #[non_exhaustive] pub struct NetworkIngressSection { - #[serde(skip_serializing_if = "Option::is_none")] pub default: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub host_loopback: Option, } /// Runtime values supplied separately from sandbox policy. -#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "camelCase")] +#[derive(Debug, Clone, Default, PartialEq, Eq)] #[non_exhaustive] pub struct RuntimeConfigSection { /// HTTP/S proxy URL. Host-process backends require localhost; WSLc requires /// a container-routable endpoint and does not filter egress through it. - #[serde(skip_serializing_if = "Option::is_none")] pub network_proxy: Option, } diff --git a/src/core/mxc_engine/src/policy/sdk_v1_goldens.rs b/src/core/mxc_engine/src/policy/sdk_v1_goldens.rs new file mode 100644 index 000000000..82e966f52 --- /dev/null +++ b/src/core/mxc_engine/src/policy/sdk_v1_goldens.rs @@ -0,0 +1,516 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Shared SDK v1 golden fixtures under `tests/policy/sdk-v1/`. +//! +//! Each `input/.json` describes a high-level policy invocation and each +//! `expected/.json` is the exact 1.0.0 request every SDK must emit for +//! it. These tests prove that the Rust policy builder and the expected document +//! produce the same normalized execution request, so an SDK that emits the +//! expected document runs with the same intent as the Rust SDK. Documents in +//! `invalid/` must be rejected by the exact parser with the recorded code. + +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 GoldenInput { + #[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 shape; the SDK policy types are plain Rust. + +#[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 InvalidGolden { + #[allow(dead_code)] + description: String, + error_code: String, + message_contains: String, + document: serde_json::Value, +} + +fn golden_dir(kind: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../tests/policy/sdk-v1") + .join(kind) +} + +fn golden_names(kind: &str) -> Vec { + let mut names: Vec = std::fs::read_dir(golden_dir(kind)) + .unwrap_or_else(|error| panic!("reading {kind} goldens: {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} goldens found"); + names +} + +fn read_golden(kind: &str, name: &str) -> String { + std::fs::read_to_string(golden_dir(kind).join(format!("{name}.json"))) + .unwrap_or_else(|error| panic!("reading {kind}/{name}.json: {error}")) +} + +fn build_from_input(name: &str, input: GoldenInput) -> 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_the_rust_builder() { + let names = golden_names("input"); + assert_eq!(names, golden_names("expected"), "input/expected pairs"); + let mut failures = Vec::new(); + for name in names { + let input: GoldenInput = serde_json::from_str(&read_golden("input", &name)) + .unwrap_or_else(|error| panic!("input/{name}.json: {error}")); + let expected = read_golden("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 the Rust builder's intent:\n{}", + failures.join("\n") + ); +} + +#[test] +fn invalid_documents_are_rejected() { + for name in golden_names("invalid") { + let golden: InvalidGolden = serde_json::from_str(&read_golden("invalid", &name)) + .unwrap_or_else(|error| panic!("invalid/{name}.json: {error}")); + let error = match parse_one_shot(&golden.document.to_string()) { + Ok(_) => panic!("invalid/{name}.json was accepted"), + Err(error) => error, + }; + assert_eq!( + error.code.as_str(), + golden.error_code, + "invalid/{name}.json: {}", + error.message + ); + assert!( + error.message.contains(&golden.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..dc10d30fc 100644 --- a/src/core/mxc_engine/src/run.rs +++ b/src/core/mxc_engine/src/run.rs @@ -77,16 +77,22 @@ 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 (see +/// [`ContainmentBackend::is_experimental`]) 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 +174,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 +291,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 +304,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 +353,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 +481,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 +505,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/models.rs b/src/core/wxc_common/src/models.rs index f4f3f66bc..22cec6a20 100644 --- a/src/core/wxc_common/src/models.rs +++ b/src/core/wxc_common/src/models.rs @@ -84,6 +84,24 @@ impl ContainmentBackend { | ContainmentBackend::Vm => None, } } + + /// Whether selecting this backend requires the caller's experimental + /// opt-in. Every variant is classified explicitly so a new backend must + /// choose production or experimental when it is added. + pub fn is_experimental(&self) -> bool { + match self { + ContainmentBackend::MicroVm + | ContainmentBackend::Hyperlight + | ContainmentBackend::WindowsSandbox => true, + ContainmentBackend::ProcessContainer + | ContainmentBackend::Wslc + | ContainmentBackend::Lxc + | ContainmentBackend::Vm + | ContainmentBackend::IsolationSession + | ContainmentBackend::Seatbelt + | ContainmentBackend::Bubblewrap => false, + } + } } impl From for ContainmentBackend { 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..0e6f187bc 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,26 @@ //! 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. They remain only until every binding moves to +//! the JSON entry points, and will be removed without an alias. +//! +//! ## Ingress rule +//! +//! Sandbox 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. //! //! ## Contract //! @@ -38,7 +53,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 +79,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; @@ -348,10 +366,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 +410,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..9fc570920 100644 --- a/src/ffi/mxc_ffi/src/request.rs +++ b/src/ffi/mxc_ffi/src/request.rs @@ -2,14 +2,27 @@ // Licensed under the MIT License. //! Co-versioned JSON request contract used by language bindings. +//! +//! **Deprecated.** This private binding request backs only the deprecated +//! [`mxc_run_request`](crate::mxc_run_request) and +//! [`mxc_spawn_request`](crate::streaming::mxc_spawn_request) exports. Bindings +//! send exact-version configuration documents to +//! [`mxc_run_json`](crate::mxc_run_json) and +//! [`mxc_spawn_json`](crate::streaming::mxc_spawn_json) instead; this module is +//! removed with those exports. use std::collections::BTreeMap; use mxc_sdk::v1::configs::{ - CaptureDenials, Lxc, ProcessContainer, ProcessContainerFilesystem, ProcessContainerNetwork, - ProcessContainerSystemSettings, ProcessContainerUi, ProcessContainerUiIsolation, Seatbelt, + CaptureDenials, CaptureDenialsMode, Lxc, ProcessContainer, ProcessContainerFilesystem, + ProcessContainerNetwork, ProcessContainerSystemSettings, ProcessContainerUi, + ProcessContainerUiIsolation, Seatbelt, +}; +use mxc_sdk::v1::policy::{ + ClipboardPolicy, FilesystemSection, NetworkAction, NetworkEgressSection, NetworkIngressSection, + NetworkPeerSection, NetworkPortSection, NetworkProtocol, NetworkRuleSection, NetworkSection, + RuntimeConfigSection, UiSection, }; -use mxc_sdk::v1::policy::{FilesystemSection, NetworkSection, UiSection}; use mxc_sdk::v1::{ build_request_with_containment, Containment, SandboxPolicy, SandboxRequest, WslcSection, }; @@ -40,11 +53,11 @@ struct RequestSpec { #[serde(rename_all = "camelCase", deny_unknown_fields)] struct RequestPolicy { #[serde(default)] - filesystem: Option, + filesystem: Option, #[serde(default)] - network: Option, + network: Option, #[serde(default)] - ui: Option, + ui: Option, #[serde(default)] timeout_ms: Option, #[serde( @@ -98,14 +111,217 @@ impl RequestPolicy { TelemetryField::Present(telemetry) => telemetry, }; let mut policy = SandboxPolicy::default(); - policy.filesystem = self.filesystem; - policy.network = self.network; - policy.ui = self.ui; + policy.filesystem = self.filesystem.map(FilesystemSpec::into_sdk); + policy.network = self.network.map(NetworkSpec::into_sdk); + policy.ui = self.ui.map(UiSpec::into_sdk); policy.timeout_ms = self.timeout_ms; (policy, telemetry) } } +// The binding request's own policy shape. The public SDK policy types are +// plain Rust types; only this deprecated request parses them from JSON. + +#[derive(Default, serde::Deserialize)] +#[serde(rename_all = "camelCase", default)] +struct FilesystemSpec { + readwrite_paths: Vec, + readonly_paths: Vec, + denied_paths: Vec, + clear_policy_on_exit: Option, +} + +impl FilesystemSpec { + fn into_sdk(self) -> FilesystemSection { + FilesystemSection { + readwrite_paths: self.readwrite_paths, + readonly_paths: self.readonly_paths, + denied_paths: self.denied_paths, + clear_policy_on_exit: self.clear_policy_on_exit, + } + } +} + +#[derive(Default, serde::Deserialize)] +#[serde(rename_all = "camelCase", default)] +struct UiSpec { + allow_windows: bool, + clipboard: ClipboardSpec, + allow_input_injection: bool, +} + +#[derive(Debug, Default, PartialEq, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +enum ClipboardSpec { + #[default] + None, + Read, + Write, + All, +} + +impl UiSpec { + fn into_sdk(self) -> UiSection { + UiSection { + allow_windows: self.allow_windows, + clipboard: match self.clipboard { + ClipboardSpec::None => ClipboardPolicy::None, + ClipboardSpec::Read => ClipboardPolicy::Read, + ClipboardSpec::Write => ClipboardPolicy::Write, + ClipboardSpec::All => ClipboardPolicy::All, + }, + allow_input_injection: self.allow_input_injection, + } + } +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct NetworkSpec { + #[serde(default)] + egress: Option, + #[serde(default)] + ingress: Option, + #[serde(default)] + runtime_config: Option, +} + +#[derive(Clone, Copy, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +enum NetworkActionSpec { + Allow, + Deny, +} + +#[derive(Debug, PartialEq, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +enum NetworkProtocolSpec { + Tcp, + Udp, + Icmp, + Any, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct NetworkPeerSpec { + cidr: String, + except: Option>, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct NetworkPortSpec { + protocol: Option, + port: Option, + end_port: Option, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct NetworkRuleSpec { + to: Option>, + ports: Option>, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct NetworkEgressSpec { + default: Option, + allow: Option>, + deny: Option>, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct NetworkIngressSpec { + default: Option, + host_loopback: Option, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct RuntimeConfigSpec { + network_proxy: Option, +} + +// The SDK network types are `#[non_exhaustive]`, so this crate starts from +// `Default` and assigns fields. +#[allow(clippy::field_reassign_with_default)] +impl NetworkSpec { + fn into_sdk(self) -> NetworkSection { + let mut network = NetworkSection::default(); + network.egress = self.egress.map(|egress| { + let mut section = NetworkEgressSection::default(); + section.default = egress.default.map(NetworkActionSpec::into_sdk); + section.allow = egress.allow.map(NetworkRuleSpec::into_sdk_list); + section.deny = egress.deny.map(NetworkRuleSpec::into_sdk_list); + section + }); + network.ingress = self.ingress.map(|ingress| { + let mut section = NetworkIngressSection::default(); + section.default = ingress.default.map(NetworkActionSpec::into_sdk); + section.host_loopback = ingress.host_loopback.map(NetworkActionSpec::into_sdk); + section + }); + network.runtime_config = self.runtime_config.map(|runtime| { + let mut section = RuntimeConfigSection::default(); + section.network_proxy = runtime.network_proxy; + section + }); + network + } +} + +impl NetworkActionSpec { + fn into_sdk(self) -> NetworkAction { + match self { + Self::Allow => NetworkAction::Allow, + Self::Deny => NetworkAction::Deny, + } + } +} + +#[allow(clippy::field_reassign_with_default)] +impl NetworkRuleSpec { + fn into_sdk_list(rules: Vec) -> Vec { + rules + .into_iter() + .map(|rule| { + let mut section = NetworkRuleSection::default(); + section.to = rule.to.map(|peers| { + peers + .into_iter() + .map(|peer| { + let mut section = NetworkPeerSection::new(peer.cidr); + section.except = peer.except; + section + }) + .collect() + }); + section.ports = rule.ports.map(|ports| { + ports + .into_iter() + .map(|port| { + let mut section = NetworkPortSection::default(); + section.protocol = port.protocol.map(|protocol| match protocol { + NetworkProtocolSpec::Tcp => NetworkProtocol::Tcp, + NetworkProtocolSpec::Udp => NetworkProtocol::Udp, + NetworkProtocolSpec::Icmp => NetworkProtocol::Icmp, + NetworkProtocolSpec::Any => NetworkProtocol::Any, + }); + section.port = port.port; + section.end_port = port.end_port; + section + }) + .collect() + }); + section + }) + .collect() + } +} + #[derive(Default, serde::Deserialize)] #[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)] enum RequestContainment { @@ -119,7 +335,7 @@ enum RequestContainment { #[serde(default)] capabilities: Vec, #[serde(default, rename = "captureDenials")] - capture_denials: Option, + capture_denials: Option, #[serde(default = "default_process_container_ui")] ui: Option, #[serde(default)] @@ -209,6 +425,35 @@ enum ProcessContainerSystemSettingsSpec { None, } +#[derive(Default, serde::Deserialize)] +#[serde(rename_all = "camelCase", default)] +struct CaptureDenialsSpec { + mode: CaptureDenialsModeSpec, + output_path: Option, + retain_etl: bool, +} + +#[derive(Default, serde::Deserialize)] +#[serde(rename_all = "kebab-case")] +enum CaptureDenialsModeSpec { + #[default] + Block, + Allow, +} + +impl CaptureDenialsSpec { + fn into_sdk(self) -> CaptureDenials { + let mut capture = CaptureDenials::default(); + capture.mode = match self.mode { + CaptureDenialsModeSpec::Block => CaptureDenialsMode::Block, + CaptureDenialsModeSpec::Allow => CaptureDenialsMode::Allow, + }; + capture.output_path = self.output_path; + capture.retain_etl = self.retain_etl; + capture + } +} + #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct ProcessContainerNetworkSpec { @@ -271,7 +516,8 @@ impl RequestContainment { process_container.least_privilege = least_privilege; process_container.learning_mode = learning_mode; process_container.capabilities = capabilities; - process_container.capture_denials = capture_denials; + process_container.capture_denials = + capture_denials.map(CaptureDenialsSpec::into_sdk); process_container.ui = ui.map(ProcessContainerUiSpec::into_sdk); process_container.filesystem = filesystem.map(ProcessContainerFilesystemSpec::into_sdk); @@ -434,6 +680,25 @@ fn malformed_request(error: serde_json::Error) -> Error { mod tests { use super::*; + #[test] + fn capture_denials_parse_from_camel_case_json() { + let spec: CaptureDenialsSpec = serde_json::from_value(serde_json::json!({ + "mode": "allow", + "outputPath": "/tmp/denials.json", + "retainEtl": true, + })) + .expect("captureDenials parses"); + let capture = spec.into_sdk(); + + assert_eq!(capture.mode, CaptureDenialsMode::Allow); + assert_eq!(capture.output_path.as_deref(), Some("/tmp/denials.json")); + assert!(capture.retain_etl); + + let spec: CaptureDenialsSpec = + serde_json::from_value(serde_json::json!({})).expect("empty captureDenials parses"); + assert_eq!(spec.into_sdk(), CaptureDenials::default()); + } + #[test] fn process_container_filesystem_is_accepted_by_native_contract() { let spec: RequestSpec = serde_json::from_str( @@ -495,7 +760,7 @@ mod tests { .as_ref() .expect("UI policy is preserved"); assert!(!ui.allow_windows); - assert_eq!(ui.clipboard, mxc_sdk::v1::policy::ClipboardPolicy::Read); + assert_eq!(ui.clipboard, ClipboardSpec::Read); assert!(!ui.allow_input_injection); let authored_network = process_spec .policy @@ -570,7 +835,7 @@ mod tests { Some(["10.20.30.0/24".to_string()].as_slice()) ); let ports = allow[0].ports.as_ref().expect("port rule is preserved"); - assert_eq!(ports[0].protocol, Some(mxc_sdk::v1::NetworkProtocol::Tcp)); + assert_eq!(ports[0].protocol, Some(NetworkProtocolSpec::Tcp)); assert_eq!(ports[0].port, Some(443)); assert_eq!(ports[0].end_port, Some(444)); build_request_from_json(directional_network) diff --git a/src/ffi/mxc_ffi/src/state_aware.rs b/src/ffi/mxc_ffi/src/state_aware.rs index af8abbcf8..2f3a71fee 100644 --- a/src/ffi/mxc_ffi/src/state_aware.rs +++ b/src/ffi/mxc_ffi/src/state_aware.rs @@ -6,13 +6,14 @@ //! 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** +//! - [`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 [`MxcSandbox`](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,27 @@ 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`]. #[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 +129,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 +145,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 +171,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() { @@ -196,8 +200,8 @@ pub unsafe extern "C" fn mxc_state_aware_result_free(r: *mut MxcStateAwareResult /// 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 +217,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 +262,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 +273,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 +326,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 +371,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 +416,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 } @@ -465,7 +471,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 +503,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 +532,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 +543,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 +559,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 +567,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 +581,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 +591,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 +604,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 +623,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 +644,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 +665,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 +690,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 +709,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 +726,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..2ccea5f20 100644 --- a/src/ffi/mxc_ffi/tests/ffi.rs +++ b/src/ffi/mxc_ffi/tests/ffi.rs @@ -9,14 +9,15 @@ use std::ffi::{CStr, CString}; use std::ptr; 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}; @@ -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..6190e4ff5 100644 --- a/tests/policy/README.md +++ b/tests/policy/README.md @@ -1,5 +1,82 @@ # Cross-language policy fixtures +This directory holds two 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 goldens](#sdk-v1-goldens). +- The top-level `*.json` files — the private binding request accepted by the + deprecated `mxc_run_request` / `mxc_spawn_request` exports. They are removed + with those exports. + +## SDK v1 goldens + +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. + +### Consumers + +- **Rust** — `src/core/mxc_engine/src/policy/sdk_v1_goldens.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** — each SDK maps the input through its own high-level API + and asserts the emitted JSON equals the expected document. + +### 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_goldens` and +`node scripts/versioning/validate-configs.js`, then update each SDK's golden +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 +90,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 +102,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,7 +113,7 @@ 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, … }`), not exact configuration documents. They are deliberately outside 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/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 + } +} From 9eb6b7e12c6065879dccfb8d28f09727715e4432 Mon Sep 17 00:00:00 2001 From: Gudge Date: Fri, 2 Oct 2026 08:49:45 -0700 Subject: [PATCH 2/4] Finish JSON ingress identity and validation closeout This PR keeps experimental backend authorization out of policy identity and delegates blank proxy-peer validation to the shared exact parser. It also clarifies the transitional binding paths and failure-result ownership while the language bindings complete their JSON ingress migrations. Details * Exclude authorization from one-shot and lifecycle hashes, with backend invariance and projection-key regression coverage. * Remove duplicate builder validation, compare its errors with shared parser diagnostics, and add the blank-peer invalid golden. * Verify lifecycle error cleanup and correct native, fixture, versioning, and telemetry documentation, including qualified Rust API links. * Adapt merged Linux LXC FFI test imports to the new common import list. Tests * cargo fmt --all -- --check passed. Both workspace commands passed: cargo check --workspace --all-targets --all-features cargo clippy --workspace --all-targets --all-features -- -D warnings * Full wxc_common/mxc_engine/mxc-sdk/mxc_ffi tests passed. Targeted tests: 31 policy identity, 3 builder validation, 3 SDK goldens, 17 lifecycle. * Linux all-target checks, macOS clippy, and Rustdoc passed with warnings denied. Elevated and native Linux/macOS backend suites were not run. * Node build/typecheck and npm test passed (389 passed, 20 skipped). .NET tests passed (299 passed, 29 skipped), plus XML docs, API parity, 40 generated bindings, and native AOT publish and smoke execution. * Versioning tests: 67 passed. Schema validation: 417 exact configs passed; SDK/schema synchronization and git diff --check passed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 30c4f940-9c8e-4d3e-89ff-b1776045bb1c Generated-with: gpt-6.1-sol --- docs/telemetry/telemetry.md | 1 + docs/versioning.md | 34 +++++----- src/core/mxc_engine/src/lib.rs | 4 +- src/core/mxc_engine/src/policy/exact/mod.rs | 65 +++++++------------ src/core/wxc_common/src/policy_identity.rs | 56 ++++++++++++++-- src/ffi/mxc_ffi/src/lib.rs | 16 +++-- src/ffi/mxc_ffi/src/request.rs | 11 ++-- src/ffi/mxc_ffi/src/state_aware.rs | 16 +++-- src/ffi/mxc_ffi/tests/ffi.rs | 4 +- tests/policy/README.md | 17 +++-- .../sdk-v1/invalid/blank-proxy-peer.json | 17 +++++ 11 files changed, 158 insertions(+), 83 deletions(-) create mode 100644 tests/policy/sdk-v1/invalid/blank-proxy-peer.json 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 d33851f83..3cddc63b6 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -295,7 +295,7 @@ descriptors. ### Native ingress -Language bindings pass configuration to the native library only as exact +The completed binding migration passes configuration to native only as exact versioned JSON. A high-level SDK maps its policy types to the contract named by its `sdkMajorTargets` entry, stamps that `version`, and calls `mxc_run_json`, `mxc_spawn_json`, or the state-aware JSON exports; raw JSON @@ -303,8 +303,15 @@ APIs pass caller-authored documents through unchanged. The native side parses each document with its declared contract, so every surface shares one parser and one normalization path. Controls that are not configuration, such as the experimental opt-in, are typed FFI arguments and never JSON fields. -The shared fixtures in `tests/policy/sdk-v1/` pin the document each SDK emits -for a given high-level policy. +The shared fixtures in `tests/policy/sdk-v1/` define the document each SDK must +emit for a given high-level policy. Rust builder/schema coverage is present; +Node and .NET emitted-document checks accompany their respective migrations. + +During the transition, Node and .NET one-shot bindings still use deprecated +`mxc_run_request` / `mxc_spawn_request`, and the .NET probe still uses the +temporary private binding-request probe. Those formats can carry an embedded +experimental switch. The exact-config exports follow the rule above; the +private exceptions are removed after the consumers migrate. ### Experimental Flag @@ -318,19 +325,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 - -Selecting an experimental backend (MicroVM, Hyperlight, or Windows Sandbox) is -the exception: without the flag, the engine refuses the request with +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 -has no effect on the choice of a production backend. +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/src/core/mxc_engine/src/lib.rs b/src/core/mxc_engine/src/lib.rs index 5bd7146e9..468be1cb3 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. @@ -108,7 +108,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. /// diff --git a/src/core/mxc_engine/src/policy/exact/mod.rs b/src/core/mxc_engine/src/policy/exact/mod.rs index fc0f5e335..538410554 100644 --- a/src/core/mxc_engine/src/policy/exact/mod.rs +++ b/src/core/mxc_engine/src/policy/exact/mod.rs @@ -37,22 +37,6 @@ 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()), @@ -89,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, @@ -128,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/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/src/lib.rs b/src/ffi/mxc_ffi/src/lib.rs index 0e6f187bc..f86c4b2b6 100644 --- a/src/ffi/mxc_ffi/src/lib.rs +++ b/src/ffi/mxc_ffi/src/lib.rs @@ -21,19 +21,25 @@ //! 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. They remain only until every binding moves to -//! the JSON entry points, and will be removed without an alias. +//! 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 //! -//! Sandbox policy and configuration cross this boundary **only** as exact +//! 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. +//! 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 //! @@ -335,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() { diff --git a/src/ffi/mxc_ffi/src/request.rs b/src/ffi/mxc_ffi/src/request.rs index 9fc570920..cd7471e21 100644 --- a/src/ffi/mxc_ffi/src/request.rs +++ b/src/ffi/mxc_ffi/src/request.rs @@ -3,13 +3,16 @@ //! Co-versioned JSON request contract used by language bindings. //! -//! **Deprecated.** This private binding request backs only the deprecated +//! **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. Bindings -//! send exact-version configuration documents to +//! [`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 those exports. +//! 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; diff --git a/src/ffi/mxc_ffi/src/state_aware.rs b/src/ffi/mxc_ffi/src/state_aware.rs index 2f3a71fee..ffe6fd8a5 100644 --- a/src/ffi/mxc_ffi/src/state_aware.rs +++ b/src/ffi/mxc_ffi/src/state_aware.rs @@ -14,7 +14,7 @@ //! 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 [`MxcSandbox`](crate::MxcSandbox) handle +//! 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. //! @@ -113,7 +113,8 @@ impl MxcStateAwareResult { /// # 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_run_state_aware_json( request_json_utf8: *const c_char, @@ -193,7 +194,7 @@ 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 @@ -459,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] diff --git a/src/ffi/mxc_ffi/tests/ffi.rs b/src/ffi/mxc_ffi/tests/ffi.rs index 2ccea5f20..d64fa330f 100644 --- a/src/ffi/mxc_ffi/tests/ffi.rs +++ b/src/ffi/mxc_ffi/tests/ffi.rs @@ -8,6 +8,8 @@ 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_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, @@ -19,8 +21,6 @@ use mxc_ffi::{ 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 { diff --git a/tests/policy/README.md b/tests/policy/README.md index 6190e4ff5..72a1816a0 100644 --- a/tests/policy/README.md +++ b/tests/policy/README.md @@ -4,9 +4,11 @@ This directory holds two 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 goldens](#sdk-v1-goldens). -- The top-level `*.json` files — the private binding request accepted by the +- 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 goldens @@ -30,8 +32,10 @@ reject, with the expected `errorCode` and a `messageContains` fragment. 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** — each SDK maps the input through its own high-level API - and asserts the emitted JSON equals the expected document. +- **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. ### Mapping rules @@ -115,9 +119,14 @@ let both test suites confirm the change is what you meant. ### 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/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" + } + } + } +} From dfe8ae6bb184c10795c1fcd6b1bd0811e8616eb0 Mon Sep 17 00:00:00 2001 From: Gudge Date: Fri, 2 Oct 2026 09:43:08 -0700 Subject: [PATCH 3/4] Retain SDK deserialization until private ingress cleanup This PR keeps the existing SDK serde support while legacy binding callers remain active. The private parser again reuses SDK policy types directly, avoiding the filesystem, network, UI, and denial-capture mirror models that would otherwise be added only to be removed by cleanup. Details * Restore the authoring derives and wire attributes needed by the existing private request path through A, B, and C. * Remove new mirror policy types and conversions while retaining earlier backend-specific adapters whose private shapes differ from SDK types. Tests * cargo test -p mxc_ffi --lib -- request::tests: 22 passed. * cargo test -p mxc_engine --lib -- policy::: 42 passed. * SDK/FFI formatting and git diff --check passed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 30c4f940-9c8e-4d3e-89ff-b1776045bb1c Generated-with: gpt-6.1-sol --- .../src/configs/process_container.rs | 6 +- src/core/mxc_engine/src/policy.rs | 16 +- src/core/mxc_engine/src/policy/network.rs | 42 ++- src/ffi/mxc_ffi/src/request.rs | 277 ++---------------- 4 files changed, 69 insertions(+), 272 deletions(-) diff --git a/src/core/mxc_engine/src/configs/process_container.rs b/src/core/mxc_engine/src/configs/process_container.rs index ec9a40325..3016638e2 100644 --- a/src/core/mxc_engine/src/configs/process_container.rs +++ b/src/core/mxc_engine/src/configs/process_container.rs @@ -4,7 +4,8 @@ //! ProcessContainer-specific configuration types and wire mapping. /// How denial capture handles ungranted access checks. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Deserialize)] +#[serde(rename_all = "kebab-case")] #[non_exhaustive] pub enum CaptureDenialsMode { /// Keep the access denied and record the denial. @@ -20,7 +21,8 @@ pub enum CaptureDenialsMode { /// ungranted access attempts and writes a JSON denials document, reported /// through /// [`SandboxOutputMetadata::capture_denials`](wxc_common::models::SandboxOutputMetadata). -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize)] +#[serde(rename_all = "camelCase", default)] #[non_exhaustive] pub struct CaptureDenials { /// How each ungranted access check is handled while it is recorded. diff --git a/src/core/mxc_engine/src/policy.rs b/src/core/mxc_engine/src/policy.rs index 12a789bf0..611d54ddc 100644 --- a/src/core/mxc_engine/src/policy.rs +++ b/src/core/mxc_engine/src/policy.rs @@ -428,7 +428,8 @@ pub fn temporary_files_policy(env: Option<&[(String, String)]>) -> FilesystemPol /// Clipboard access level, mirroring the SDK `ClipboardPolicy` /// (`"none" | "read" | "write" | "all"`). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)] +#[serde(rename_all = "camelCase")] pub enum ClipboardPolicy { /// No clipboard access. #[default] @@ -442,7 +443,8 @@ pub enum ClipboardPolicy { } /// Filesystem section of a [`SandboxPolicy`]. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, serde::Deserialize)] +#[serde(rename_all = "camelCase", default)] pub struct FilesystemSection { pub readwrite_paths: Vec, pub readonly_paths: Vec, @@ -452,7 +454,8 @@ pub struct FilesystemSection { } /// UI section of a [`SandboxPolicy`]. All flags default to denied. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, serde::Deserialize)] +#[serde(rename_all = "camelCase", default)] pub struct UiSection { pub allow_windows: bool, pub clipboard: ClipboardPolicy, @@ -570,13 +573,18 @@ impl Default for WslcSection { /// instrumentation rather than a sandbox restriction, matching the global /// sandbox-policy design. Build the request first, then use /// [`SandboxRequest::set_telemetry_opt_in`] to opt that invocation in. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] #[non_exhaustive] pub struct SandboxPolicy { + #[serde(default)] pub filesystem: Option, + #[serde(default)] pub network: Option, + #[serde(default)] pub ui: Option, /// Execution timeout in milliseconds (`None` = no timeout). + #[serde(default)] pub timeout_ms: Option, } diff --git a/src/core/mxc_engine/src/policy/network.rs b/src/core/mxc_engine/src/policy/network.rs index cee674024..e6c7ffac6 100644 --- a/src/core/mxc_engine/src/policy/network.rs +++ b/src/core/mxc_engine/src/policy/network.rs @@ -7,19 +7,24 @@ /// /// The v1 high-level SDK exposes directional policy only. Exact legacy /// configuration remains available through the raw configuration parser. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] #[non_exhaustive] pub struct NetworkSection { /// Outbound network policy. + #[serde(default)] pub egress: Option, /// Inbound and host-loopback network policy. + #[serde(default)] pub ingress: Option, /// Runtime values supplied separately from sandbox policy. + #[serde(default)] pub runtime_config: Option, } /// Allow or deny a network action. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] #[non_exhaustive] pub enum NetworkAction { Allow, @@ -28,7 +33,8 @@ pub enum NetworkAction { } /// Transport protocol selector. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] #[non_exhaustive] pub enum NetworkProtocol { Tcp, @@ -38,10 +44,12 @@ pub enum NetworkProtocol { } /// CIDR network peer. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct NetworkPeerSection { pub cidr: String, + #[serde(skip_serializing_if = "Option::is_none")] pub except: Option>, } @@ -56,45 +64,61 @@ impl NetworkPeerSection { } /// Protocol and destination-port selector. -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct NetworkPortSection { + #[serde(skip_serializing_if = "Option::is_none")] pub protocol: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub port: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub end_port: Option, } /// Outbound network rule. -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct NetworkRuleSection { + #[serde(skip_serializing_if = "Option::is_none")] pub to: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub ports: Option>, } /// Outbound network policy. -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct NetworkEgressSection { + #[serde(skip_serializing_if = "Option::is_none")] pub default: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub allow: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub deny: Option>, } /// Inbound and host-loopback network policy. -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct NetworkIngressSection { + #[serde(skip_serializing_if = "Option::is_none")] pub default: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub host_loopback: Option, } /// Runtime values supplied separately from sandbox policy. -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct RuntimeConfigSection { /// HTTP/S proxy URL. Host-process backends require localhost; WSLc requires /// a container-routable endpoint and does not filter egress through it. + #[serde(skip_serializing_if = "Option::is_none")] pub network_proxy: Option, } diff --git a/src/ffi/mxc_ffi/src/request.rs b/src/ffi/mxc_ffi/src/request.rs index cd7471e21..3d5655f84 100644 --- a/src/ffi/mxc_ffi/src/request.rs +++ b/src/ffi/mxc_ffi/src/request.rs @@ -17,15 +17,10 @@ use std::collections::BTreeMap; use mxc_sdk::v1::configs::{ - CaptureDenials, CaptureDenialsMode, Lxc, ProcessContainer, ProcessContainerFilesystem, - ProcessContainerNetwork, ProcessContainerSystemSettings, ProcessContainerUi, - ProcessContainerUiIsolation, Seatbelt, -}; -use mxc_sdk::v1::policy::{ - ClipboardPolicy, FilesystemSection, NetworkAction, NetworkEgressSection, NetworkIngressSection, - NetworkPeerSection, NetworkPortSection, NetworkProtocol, NetworkRuleSection, NetworkSection, - RuntimeConfigSection, UiSection, + CaptureDenials, Lxc, ProcessContainer, ProcessContainerFilesystem, ProcessContainerNetwork, + ProcessContainerSystemSettings, ProcessContainerUi, ProcessContainerUiIsolation, Seatbelt, }; +use mxc_sdk::v1::policy::{FilesystemSection, NetworkSection, UiSection}; use mxc_sdk::v1::{ build_request_with_containment, Containment, SandboxPolicy, SandboxRequest, WslcSection, }; @@ -56,11 +51,11 @@ struct RequestSpec { #[serde(rename_all = "camelCase", deny_unknown_fields)] struct RequestPolicy { #[serde(default)] - filesystem: Option, + filesystem: Option, #[serde(default)] - network: Option, + network: Option, #[serde(default)] - ui: Option, + ui: Option, #[serde(default)] timeout_ms: Option, #[serde( @@ -114,217 +109,14 @@ impl RequestPolicy { TelemetryField::Present(telemetry) => telemetry, }; let mut policy = SandboxPolicy::default(); - policy.filesystem = self.filesystem.map(FilesystemSpec::into_sdk); - policy.network = self.network.map(NetworkSpec::into_sdk); - policy.ui = self.ui.map(UiSpec::into_sdk); + policy.filesystem = self.filesystem; + policy.network = self.network; + policy.ui = self.ui; policy.timeout_ms = self.timeout_ms; (policy, telemetry) } } -// The binding request's own policy shape. The public SDK policy types are -// plain Rust types; only this deprecated request parses them from JSON. - -#[derive(Default, serde::Deserialize)] -#[serde(rename_all = "camelCase", default)] -struct FilesystemSpec { - readwrite_paths: Vec, - readonly_paths: Vec, - denied_paths: Vec, - clear_policy_on_exit: Option, -} - -impl FilesystemSpec { - fn into_sdk(self) -> FilesystemSection { - FilesystemSection { - readwrite_paths: self.readwrite_paths, - readonly_paths: self.readonly_paths, - denied_paths: self.denied_paths, - clear_policy_on_exit: self.clear_policy_on_exit, - } - } -} - -#[derive(Default, serde::Deserialize)] -#[serde(rename_all = "camelCase", default)] -struct UiSpec { - allow_windows: bool, - clipboard: ClipboardSpec, - allow_input_injection: bool, -} - -#[derive(Debug, Default, PartialEq, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -enum ClipboardSpec { - #[default] - None, - Read, - Write, - All, -} - -impl UiSpec { - fn into_sdk(self) -> UiSection { - UiSection { - allow_windows: self.allow_windows, - clipboard: match self.clipboard { - ClipboardSpec::None => ClipboardPolicy::None, - ClipboardSpec::Read => ClipboardPolicy::Read, - ClipboardSpec::Write => ClipboardPolicy::Write, - ClipboardSpec::All => ClipboardPolicy::All, - }, - allow_input_injection: self.allow_input_injection, - } - } -} - -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct NetworkSpec { - #[serde(default)] - egress: Option, - #[serde(default)] - ingress: Option, - #[serde(default)] - runtime_config: Option, -} - -#[derive(Clone, Copy, serde::Deserialize)] -#[serde(rename_all = "lowercase")] -enum NetworkActionSpec { - Allow, - Deny, -} - -#[derive(Debug, PartialEq, serde::Deserialize)] -#[serde(rename_all = "lowercase")] -enum NetworkProtocolSpec { - Tcp, - Udp, - Icmp, - Any, -} - -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct NetworkPeerSpec { - cidr: String, - except: Option>, -} - -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct NetworkPortSpec { - protocol: Option, - port: Option, - end_port: Option, -} - -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct NetworkRuleSpec { - to: Option>, - ports: Option>, -} - -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct NetworkEgressSpec { - default: Option, - allow: Option>, - deny: Option>, -} - -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct NetworkIngressSpec { - default: Option, - host_loopback: Option, -} - -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct RuntimeConfigSpec { - network_proxy: Option, -} - -// The SDK network types are `#[non_exhaustive]`, so this crate starts from -// `Default` and assigns fields. -#[allow(clippy::field_reassign_with_default)] -impl NetworkSpec { - fn into_sdk(self) -> NetworkSection { - let mut network = NetworkSection::default(); - network.egress = self.egress.map(|egress| { - let mut section = NetworkEgressSection::default(); - section.default = egress.default.map(NetworkActionSpec::into_sdk); - section.allow = egress.allow.map(NetworkRuleSpec::into_sdk_list); - section.deny = egress.deny.map(NetworkRuleSpec::into_sdk_list); - section - }); - network.ingress = self.ingress.map(|ingress| { - let mut section = NetworkIngressSection::default(); - section.default = ingress.default.map(NetworkActionSpec::into_sdk); - section.host_loopback = ingress.host_loopback.map(NetworkActionSpec::into_sdk); - section - }); - network.runtime_config = self.runtime_config.map(|runtime| { - let mut section = RuntimeConfigSection::default(); - section.network_proxy = runtime.network_proxy; - section - }); - network - } -} - -impl NetworkActionSpec { - fn into_sdk(self) -> NetworkAction { - match self { - Self::Allow => NetworkAction::Allow, - Self::Deny => NetworkAction::Deny, - } - } -} - -#[allow(clippy::field_reassign_with_default)] -impl NetworkRuleSpec { - fn into_sdk_list(rules: Vec) -> Vec { - rules - .into_iter() - .map(|rule| { - let mut section = NetworkRuleSection::default(); - section.to = rule.to.map(|peers| { - peers - .into_iter() - .map(|peer| { - let mut section = NetworkPeerSection::new(peer.cidr); - section.except = peer.except; - section - }) - .collect() - }); - section.ports = rule.ports.map(|ports| { - ports - .into_iter() - .map(|port| { - let mut section = NetworkPortSection::default(); - section.protocol = port.protocol.map(|protocol| match protocol { - NetworkProtocolSpec::Tcp => NetworkProtocol::Tcp, - NetworkProtocolSpec::Udp => NetworkProtocol::Udp, - NetworkProtocolSpec::Icmp => NetworkProtocol::Icmp, - NetworkProtocolSpec::Any => NetworkProtocol::Any, - }); - section.port = port.port; - section.end_port = port.end_port; - section - }) - .collect() - }); - section - }) - .collect() - } -} - #[derive(Default, serde::Deserialize)] #[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)] enum RequestContainment { @@ -338,7 +130,7 @@ enum RequestContainment { #[serde(default)] capabilities: Vec, #[serde(default, rename = "captureDenials")] - capture_denials: Option, + capture_denials: Option, #[serde(default = "default_process_container_ui")] ui: Option, #[serde(default)] @@ -428,35 +220,6 @@ enum ProcessContainerSystemSettingsSpec { None, } -#[derive(Default, serde::Deserialize)] -#[serde(rename_all = "camelCase", default)] -struct CaptureDenialsSpec { - mode: CaptureDenialsModeSpec, - output_path: Option, - retain_etl: bool, -} - -#[derive(Default, serde::Deserialize)] -#[serde(rename_all = "kebab-case")] -enum CaptureDenialsModeSpec { - #[default] - Block, - Allow, -} - -impl CaptureDenialsSpec { - fn into_sdk(self) -> CaptureDenials { - let mut capture = CaptureDenials::default(); - capture.mode = match self.mode { - CaptureDenialsModeSpec::Block => CaptureDenialsMode::Block, - CaptureDenialsModeSpec::Allow => CaptureDenialsMode::Allow, - }; - capture.output_path = self.output_path; - capture.retain_etl = self.retain_etl; - capture - } -} - #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct ProcessContainerNetworkSpec { @@ -519,8 +282,7 @@ impl RequestContainment { process_container.least_privilege = least_privilege; process_container.learning_mode = learning_mode; process_container.capabilities = capabilities; - process_container.capture_denials = - capture_denials.map(CaptureDenialsSpec::into_sdk); + process_container.capture_denials = capture_denials; process_container.ui = ui.map(ProcessContainerUiSpec::into_sdk); process_container.filesystem = filesystem.map(ProcessContainerFilesystemSpec::into_sdk); @@ -685,21 +447,22 @@ mod tests { #[test] fn capture_denials_parse_from_camel_case_json() { - let spec: CaptureDenialsSpec = serde_json::from_value(serde_json::json!({ + let capture: CaptureDenials = serde_json::from_value(serde_json::json!({ "mode": "allow", "outputPath": "/tmp/denials.json", "retainEtl": true, })) .expect("captureDenials parses"); - let capture = spec.into_sdk(); - - assert_eq!(capture.mode, CaptureDenialsMode::Allow); + 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 spec: CaptureDenialsSpec = + let capture: CaptureDenials = serde_json::from_value(serde_json::json!({})).expect("empty captureDenials parses"); - assert_eq!(spec.into_sdk(), CaptureDenials::default()); + assert_eq!(capture, CaptureDenials::default()); } #[test] @@ -763,7 +526,7 @@ mod tests { .as_ref() .expect("UI policy is preserved"); assert!(!ui.allow_windows); - assert_eq!(ui.clipboard, ClipboardSpec::Read); + assert_eq!(ui.clipboard, mxc_sdk::v1::policy::ClipboardPolicy::Read); assert!(!ui.allow_input_injection); let authored_network = process_spec .policy @@ -838,7 +601,7 @@ mod tests { Some(["10.20.30.0/24".to_string()].as_slice()) ); let ports = allow[0].ports.as_ref().expect("port rule is preserved"); - assert_eq!(ports[0].protocol, Some(NetworkProtocolSpec::Tcp)); + assert_eq!(ports[0].protocol, Some(mxc_sdk::v1::NetworkProtocol::Tcp)); assert_eq!(ports[0].port, Some(443)); assert_eq!(ports[0].end_port, Some(444)); build_request_from_json(directional_network) From 477ef7f862f292401b56aa851ed9a61140129438 Mon Sep 17 00:00:00 2001 From: Gudge Date: Fri, 2 Oct 2026 12:57:33 -0700 Subject: [PATCH 4/4] Clarify SDK conformance and centralize backend classification This PR clarifies how typed SDK requests and exact JSON reach the same normalized execution request, and moves experimental backend classification from the shared model into an engine-owned registration table. Details * Restore the versioning big picture and document staged native ingress. * Rename the Rust fixture harness to SDK conformance, explain its independent expected documents and test-only access to private request internals. * Register all backends in the engine, retaining explicit authorization classification and existing host-probe and dispatch behavior. Tests * cargo fmt --all -- --check; cargo check --workspace --all-targets --all-features; cargo clippy --workspace --all-targets --all-features -- -D warnings: passed. * cargo test -p mxc_engine -p mxc_ffi -p mxc-sdk -p wxc_common --all-features: passed. * Default-feature all-target Linux and macOS SDK/engine/FFI checks passed. * RUSTDOCFLAGS=-D warnings cargo doc -p mxc_engine -p mxc_ffi -p mxc-sdk --all-features --no-deps: passed. * node scripts/versioning/validate-configs.js: 417 configs validated. * Native Linux/macOS and live host-dependent suites were not run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 30c4f940-9c8e-4d3e-89ff-b1776045bb1c Generated-with: gpt-6-sol --- docs/architecture.md | 7 ++ docs/versioning.md | 114 +++++++++++++----- src/core/mxc_engine/src/backend_registry.rs | 105 ++++++++++++++++ src/core/mxc_engine/src/experimental.rs | 11 +- src/core/mxc_engine/src/lib.rs | 1 + src/core/mxc_engine/src/policy.rs | 2 +- ...dk_v1_goldens.rs => sdk_v1_conformance.rs} | 67 +++++----- src/core/mxc_engine/src/run.rs | 3 +- src/core/wxc_common/src/models.rs | 18 --- tests/policy/README.md | 37 +++++- 10 files changed, 277 insertions(+), 88 deletions(-) create mode 100644 src/core/mxc_engine/src/backend_registry.rs rename src/core/mxc_engine/src/policy/{sdk_v1_goldens.rs => sdk_v1_conformance.rs} (86%) 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/versioning.md b/docs/versioning.md index 3cddc63b6..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 @@ -295,23 +353,23 @@ descriptors. ### Native ingress -The completed binding migration passes configuration to native only as exact -versioned JSON. A high-level SDK maps its policy types to the contract named -by its `sdkMajorTargets` entry, stamps that `version`, and calls -`mxc_run_json`, `mxc_spawn_json`, or the state-aware JSON exports; raw JSON -APIs pass caller-authored documents through unchanged. The native side -parses each document with its declared contract, so every surface shares one -parser and one normalization path. Controls that are not configuration, such -as the experimental opt-in, are typed FFI arguments and never JSON fields. -The shared fixtures in `tests/policy/sdk-v1/` define the document each SDK must -emit for a given high-level policy. Rust builder/schema coverage is present; -Node and .NET emitted-document checks accompany their respective migrations. - -During the transition, Node and .NET one-shot bindings still use deprecated -`mxc_run_request` / `mxc_spawn_request`, and the .NET probe still uses the -temporary private binding-request probe. Those formats can carry an embedded -experimental switch. The exact-config exports follow the rule above; the -private exceptions are removed after the consumers migrate. +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 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/experimental.rs b/src/core/mxc_engine/src/experimental.rs index 5ff6cc478..fafd5cd25 100644 --- a/src/core/mxc_engine/src/experimental.rs +++ b/src/core/mxc_engine/src/experimental.rs @@ -3,8 +3,8 @@ //! Runtime authorization for experimental backends. //! -//! Backends are either production or experimental -//! ([`ContainmentBackend::is_experimental`]). The caller's experimental opt-in +//! 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 @@ -14,16 +14,19 @@ 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> { - if backend.is_experimental() && !experimental_enabled { + 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", - backend.wire_name() + metadata.backend.wire_name() ))); } Ok(()) diff --git a/src/core/mxc_engine/src/lib.rs b/src/core/mxc_engine/src/lib.rs index 468be1cb3..89f3653e6 100644 --- a/src/core/mxc_engine/src/lib.rs +++ b/src/core/mxc_engine/src/lib.rs @@ -29,6 +29,7 @@ //! - [`Error`] / [`ErrorCode`] — the crate-owned error facade over //! `wxc_common`'s internal error type. +mod backend_registry; pub mod configs; mod dispatch; mod error; diff --git a/src/core/mxc_engine/src/policy.rs b/src/core/mxc_engine/src/policy.rs index 611d54ddc..7b13f927e 100644 --- a/src/core/mxc_engine/src/policy.rs +++ b/src/core/mxc_engine/src/policy.rs @@ -14,7 +14,7 @@ mod exact; pub(crate) mod network; #[cfg(test)] -mod sdk_v1_goldens; +mod sdk_v1_conformance; use std::borrow::Cow; use std::collections::HashSet; diff --git a/src/core/mxc_engine/src/policy/sdk_v1_goldens.rs b/src/core/mxc_engine/src/policy/sdk_v1_conformance.rs similarity index 86% rename from src/core/mxc_engine/src/policy/sdk_v1_goldens.rs rename to src/core/mxc_engine/src/policy/sdk_v1_conformance.rs index 82e966f52..420fbfde7 100644 --- a/src/core/mxc_engine/src/policy/sdk_v1_goldens.rs +++ b/src/core/mxc_engine/src/policy/sdk_v1_conformance.rs @@ -1,14 +1,23 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Shared SDK v1 golden fixtures under `tests/policy/sdk-v1/`. +//! SDK v1 conformance tests using `tests/policy/sdk-v1/`. //! -//! Each `input/.json` describes a high-level policy invocation and each -//! `expected/.json` is the exact 1.0.0 request every SDK must emit for -//! it. These tests prove that the Rust policy builder and the expected document -//! produce the same normalized execution request, so an SDK that emits the -//! expected document runs with the same intent as the Rust SDK. Documents in -//! `invalid/` must be rejected by the exact parser with the recorded code. +//! `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}; @@ -29,7 +38,7 @@ use super::{ #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -struct GoldenInput { +struct ConformanceInput { #[allow(dead_code)] description: String, policy: PolicyInput, @@ -90,7 +99,7 @@ enum ContainmentInput { } // The input files describe the high-level policy in SDK-neutral JSON. These -// test-only types read that shape; the SDK policy types are plain Rust. +// test-only types read that invocation shape, not an exact config contract. #[derive(Default, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -350,7 +359,7 @@ impl ContainmentInput { #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -struct InvalidGolden { +struct InvalidDocument { #[allow(dead_code)] description: String, error_code: String, @@ -358,30 +367,30 @@ struct InvalidGolden { document: serde_json::Value, } -fn golden_dir(kind: &str) -> PathBuf { +fn fixture_dir(kind: &str) -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../../tests/policy/sdk-v1") .join(kind) } -fn golden_names(kind: &str) -> Vec { - let mut names: Vec = std::fs::read_dir(golden_dir(kind)) - .unwrap_or_else(|error| panic!("reading {kind} goldens: {error}")) +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} goldens found"); + assert!(!names.is_empty(), "no {kind} fixtures found"); names } -fn read_golden(kind: &str, name: &str) -> String { - std::fs::read_to_string(golden_dir(kind).join(format!("{name}.json"))) +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: GoldenInput) -> ExecutionRequest { +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( @@ -448,14 +457,14 @@ fn differences(path: &str, built: &serde_json::Value, parsed: &serde_json::Value } #[test] -fn expected_documents_match_the_rust_builder() { - let names = golden_names("input"); - assert_eq!(names, golden_names("expected"), "input/expected pairs"); +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: GoldenInput = serde_json::from_str(&read_golden("input", &name)) + let input: ConformanceInput = serde_json::from_str(&read_fixture("input", &name)) .unwrap_or_else(|error| panic!("input/{name}.json: {error}")); - let expected = read_golden("expected", &name); + 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"); @@ -469,28 +478,28 @@ fn expected_documents_match_the_rust_builder() { } assert!( failures.is_empty(), - "expected documents differ from the Rust builder's intent:\n{}", + "expected documents differ from SDK request-construction intent:\n{}", failures.join("\n") ); } #[test] fn invalid_documents_are_rejected() { - for name in golden_names("invalid") { - let golden: InvalidGolden = serde_json::from_str(&read_golden("invalid", &name)) + 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(&golden.document.to_string()) { + 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(), - golden.error_code, + fixture.error_code, "invalid/{name}.json: {}", error.message ); assert!( - error.message.contains(&golden.message_contains), + error.message.contains(&fixture.message_contains), "invalid/{name}.json was rejected for another reason: {}", error.message ); diff --git a/src/core/mxc_engine/src/run.rs b/src/core/mxc_engine/src/run.rs index dc10d30fc..c4c9699c2 100644 --- a/src/core/mxc_engine/src/run.rs +++ b/src/core/mxc_engine/src/run.rs @@ -77,8 +77,7 @@ impl ResolvedRunner { /// logging the selected isolation tier and any tier-selection warnings to /// `logger`, and surfacing the DACL guard in the returned [`ResolvedRunner`]. /// -/// Experimental backends (see -/// [`ContainmentBackend::is_experimental`]) require +/// 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 / diff --git a/src/core/wxc_common/src/models.rs b/src/core/wxc_common/src/models.rs index 22cec6a20..f4f3f66bc 100644 --- a/src/core/wxc_common/src/models.rs +++ b/src/core/wxc_common/src/models.rs @@ -84,24 +84,6 @@ impl ContainmentBackend { | ContainmentBackend::Vm => None, } } - - /// Whether selecting this backend requires the caller's experimental - /// opt-in. Every variant is classified explicitly so a new backend must - /// choose production or experimental when it is added. - pub fn is_experimental(&self) -> bool { - match self { - ContainmentBackend::MicroVm - | ContainmentBackend::Hyperlight - | ContainmentBackend::WindowsSandbox => true, - ContainmentBackend::ProcessContainer - | ContainmentBackend::Wslc - | ContainmentBackend::Lxc - | ContainmentBackend::Vm - | ContainmentBackend::IsolationSession - | ContainmentBackend::Seatbelt - | ContainmentBackend::Bubblewrap => false, - } - } } impl From for ContainmentBackend { diff --git a/tests/policy/README.md b/tests/policy/README.md index 72a1816a0..f18c29511 100644 --- a/tests/policy/README.md +++ b/tests/policy/README.md @@ -1,16 +1,17 @@ # Cross-language policy fixtures -This directory holds two fixture families: +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 goldens](#sdk-v1-goldens). + 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 goldens +## SDK v1 conformance fixtures Each case is a pair of files with the same name: @@ -24,9 +25,28 @@ Each case is a pair of files with the same name: `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_goldens.rs` builds each +- **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. @@ -37,6 +57,11 @@ reject, with the expected `errorCode` and a `messageContains` fragment. 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: @@ -75,8 +100,8 @@ The expected documents follow these rules, which every SDK mapper applies: ### Adding a case Write both files by hand. Run -`cargo test -p mxc_engine -- sdk_v1_goldens` and -`node scripts/versioning/validate-configs.js`, then update each SDK's golden +`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