diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3ca324980..3e8b020fd 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -173,7 +173,7 @@ The workspace is organized into five top-level directories under `src/`: | Directory | Purpose | Examples | |-----------|---------|----------| -| `core/` | Cross-platform foundation + per-platform aggregator binaries | `wxc_common/`, `wxc/`, `lxc/`, `mxc_darwin/`, `mxc_pty/`, `mxc_build_common/`, `generated/` | +| `core/` | Cross-platform foundation + per-platform aggregator binaries | `wxc_common/`, `wxc/`, `lxc/`, `mxc_darwin/`, `mxc-sdk/`, `mxc_pty/`, `mxc_build_common/`, `generated/` | | `backends/` | Backend-specific code (one subfolder per containment backend) | `appcontainer/common`, `windows_sandbox/{daemon,guest,common}`, `isolation_session/{bindings,common}`, `hyperlight/common`, `nanvix/{common,build_common,binaries,runner}`, `lxc/common`, `bubblewrap/common`, `wslc/common`, `seatbelt/common` | | `host/` | Host-side utilities | `wxc_host_prep/`, `wxc_winhttp_proxy_shim/` | | `testing/` | Test infrastructure crates | `wxc_e2e_tests/`, `wxc_test_driver/`, `wxc_test_proxy/`, `linux_test_proxy/`, `wxc_ui_probe/`, `fuzz/` | @@ -182,7 +182,8 @@ The workspace is organized into five top-level directories under `src/`: - `wxc_common` is the **cross-platform foundation**: config parsing, models, errors, logger, `ScriptRunner` / `StatefulSandboxBackend` traits, state-aware dispatch helpers, validators, ids, ui-policy, encoding. Plus a few thin Windows API helpers shared by host tools and backends (`process_util`, `string_util`, `filesystem_dacl`, `diagnostic`). It must not depend on any `backends/*` crate. - Each Windows containment backend lives in its own `backends/*/common` crate (e.g. `appcontainer_common`, `windows_sandbox_common`, `isolation_session_common`, `hyperlight_common`, `nanvix_runner`). Backend crates depend on `wxc_common`; there are no cross-edges between backend crates. - `wxc` and `lxc` are thin binary crates that wire up CLI args (`clap`) and dispatch to `wxc_common` and the per-backend crates -- `mxc_pty` is the shared pty bridge used by the unix-side backends (`lxc_common::lxc_bindings::attach_run` on Linux and `seatbelt_common::seatbelt_runner` on macOS) so the inner shell sees a real TTY and host stdio is streamed live +- `mxc-sdk` is an **importable library** for starting sandboxes in-process without a pty: `spawn_sandbox` takes a `SandboxRequest` (from `build_request`), selects the host backend, and returns a `Sandbox` handle for persistent bidirectional stdio (`take_stdin`/`take_stdout`/`take_stderr`), `kill()`, and `wait()` (which drains and discards any untaken stdout/stderr and returns a `WaitOutcome` — `Exited(i32)` or `TimedOut` — as `io::Result`, reserving `Err` for an actual OS/wait failure), or `wait_with_output()` (consumes the handle, drains both streams concurrently, returns an `Output` with the `WaitOutcome` + captured `stdout`/`stderr`). It additionally ports the SDK's config-building surface so callers don't need the TypeScript module: `mxc_sdk::policy` (`SandboxPolicy` + `build_request` → `SandboxRequest` (opaque wrapper mapping to the internal `ExecutionRequest`), the port of `createConfigFromPolicy`; plus `available_tools_policy`/`user_profile_policy`/`temporary_files_policy` discovery helpers) and `mxc_sdk::platform_support` (port of `getPlatformSupport`, using the in-process probe on Windows). It depends on the backend crates (cfg-split: appcontainer on Windows, bubblewrap on Linux, seatbelt on macOS) — so it can't live in `wxc_common`. The public surface is deliberately minimal (streaming only): the `dispatch` and `platform` modules are private and only their used items are re-exported at the crate root (`platform_support`, `PlatformSupport`); `policy` is the one public submodule (callers name `mxc_sdk::policy::{SandboxPolicy sections}`). The execution surface lives in `wxc_common::sandbox_process`: the `SandboxBackend` trait (`validate` + `spawn(request, logger, StdioMode) -> Box` + a `diagnose_exit` hook for enriching launch-failure exits) and the generic `Runner` adapter that bridges any `SandboxBackend` to the run-to-completion `ScriptRunner` (by calling `spawn(StdioMode::Inherit)` then `wait()`). `StdioMode::Pipes` hands the caller live stdin/stdout/stderr (what `mxc-sdk` uses); `StdioMode::Inherit` lets the child inherit the host process's own stdio (what the executor binaries use, preserving the TTY under a pty). `SandboxBackend` is implemented for every library backend — Seatbelt (macOS), Bubblewrap (Linux), and Windows ProcessContainer (AppContainer + BaseContainer). The `wxc`/`lxc`/`mxc_darwin` executor binaries do **not** depend on `mxc-sdk`; they keep their own backend dispatch (sharing only the lower-level `appcontainer_common::dispatcher::dispatch_with_fallback`). The `mxc-sdk` in-crate backend dispatch (`dispatch.rs`) and host probing (`platform.rs`) are **provisional** — a follow-up will move them into a dedicated `mxc` engine crate that both `mxc-sdk` and the executor binaries call into. +- `mxc_pty` is the shared pty bridge used by the LXC backend (`lxc_common::lxc_bindings::attach_run`) so the inner shell sees a real TTY and host stdio is streamed live. (Seatbelt and Bubblewrap no longer use it: they spawn directly and let the child inherit the host's stdio — a TTY when the executor binary runs under a pty — via `SandboxBackend::spawn(StdioMode::Inherit)`.) - `mxc_build_common` is a build-time helper crate — all Windows binary crates use it in their `build.rs` to embed VersionInfo (ProductName, FileDescription, copyright, version+commit). When adding a new Windows binary crate, add `mxc_build_common` as a build-dependency and call `mxc_build_common::embed_version_info()` from `build.rs` - `nanvix_build_common` is a **build-only** helper crate (never linked into the runtime): it stages NanVix binaries next to the executable and resolves the `NANVIX_BIN` prefetch directory. The `nanvix_binaries`, `wxc`, and `lxc` build scripts consume it as a `[build-dependencies]` entry. Runtime constants it needs (binary/snapshot filenames) stay in `nanvix_common`. Keep build-only file-staging logic here, not in `nanvix_common` (which is a runtime dependency of `nanvix_runner`). - Platform-specific modules use `#[cfg(target_os = "windows")]` / `#[cfg(target_os = "linux")]` diff --git a/docs/sandbox-policy/v1/policy.md b/docs/sandbox-policy/v1/policy.md index e7f12ced3..a4c1e8a5c 100644 --- a/docs/sandbox-policy/v1/policy.md +++ b/docs/sandbox-policy/v1/policy.md @@ -217,8 +217,8 @@ All flags default to `false` (no network access). |--------------------|-------------| | `allowOutbound` | Allow outbound connections to the internet (HTTP, DNS, etc.). | | `allowLocalNetwork`| Allow connections to local networks. | -| `allowedHosts` | When set, ONLY these outbound hosts are reachable. Error if `allowOutbound` is not set. | -| `blockedHosts` | Hosts to block even when outbound is allowed. Error if `allowOutbound` is not set. | +| `allowedHosts` | When set, ONLY these outbound hosts are reachable. Host-filtering backends (Linux, macOS) accept this without `allowOutbound`; Windows ProcessContainer requires `allowOutbound`. | +| `blockedHosts` | Hosts to block even when outbound is allowed. Same `allowOutbound` requirement as `allowedHosts` (Windows ProcessContainer only). | | `proxy` | `{ builtinTestServer: true }` or `{ url: "..." }`. Routes all traffic through this proxy. Cannot be combined with other network flags. | Omitted = no network access. diff --git a/src/Cargo.lock b/src/Cargo.lock index cead9eaad..26ff4e3d1 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -1385,6 +1385,19 @@ dependencies = [ "vmm-sys-util", ] +[[package]] +name = "mxc-sdk" +version = "0.7.0" +dependencies = [ + "appcontainer_common", + "bwrap_common", + "libc", + "seatbelt_common", + "serde", + "serde_json", + "wxc_common", +] + [[package]] name = "mxc_build_common" version = "0.7.0" diff --git a/src/Cargo.toml b/src/Cargo.toml index 458fd2f28..84e7ba361 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -4,6 +4,7 @@ members = [ "core/wxc_common", "core/lxc", "core/mxc_darwin", + "core/mxc-sdk", "core/mxc_pty", "core/mxc_build_common", "core/generated/base_container_specification", @@ -102,6 +103,7 @@ libc = "0.2" nix = { version = "0.29", features = ["fs", "mount", "sched", "signal", "net", "process", "user", "term"] } lxc_common = { path = "backends/lxc/common" } bwrap_common = { path = "backends/bubblewrap/common" } +seatbelt_common = { path = "backends/seatbelt/common" } wslc_common = { path = "backends/wslc/common" } isolation_session_bindings = { path = "backends/isolation_session/bindings" } mxc_pty = { path = "core/mxc_pty" } diff --git a/src/core/mxc-sdk/Cargo.toml b/src/core/mxc-sdk/Cargo.toml new file mode 100644 index 000000000..ce9a14260 --- /dev/null +++ b/src/core/mxc-sdk/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "mxc-sdk" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Importable library for starting MXC sandboxes in-process (no pty, streaming stdio)." + +[lib] +name = "mxc_sdk" +path = "src/lib.rs" + +[dependencies] +wxc_common.workspace = true +serde = { workspace = true } +serde_json = { workspace = true } + +[target.'cfg(target_os = "windows")'.dependencies] +appcontainer_common = { workspace = true } + +[target.'cfg(target_os = "linux")'.dependencies] +bwrap_common = { workspace = true } + +[target.'cfg(target_os = "macos")'.dependencies] +seatbelt_common = { workspace = true } + +[target.'cfg(target_os = "macos")'.dev-dependencies] +libc = { workspace = true } diff --git a/src/core/mxc-sdk/README.md b/src/core/mxc-sdk/README.md new file mode 100644 index 000000000..6c20c0709 --- /dev/null +++ b/src/core/mxc-sdk/README.md @@ -0,0 +1,152 @@ +# `mxc-sdk` + +An importable Rust library for starting [MXC](../../../README.md) sandboxes +**in-process**, without ever allocating a pty. + +Build a `SandboxRequest` from a [`SandboxPolicy`], then hand it to +[`spawn_sandbox`]: it selects the +right containment backend for the host and spawns the sandboxed process — +returning a handle for live bidirectional stdio and termination. + +## Usage + +```rust,no_run +use std::io::Read; +use mxc_sdk::{build_request, spawn_sandbox, SandboxPolicy, WaitOutcome}; + +// Describe what to restrict, turn it into a request, fill in the command. +let policy = SandboxPolicy { + version: "0.7.0-alpha".to_string(), + filesystem: None, + network: None, + ui: None, + timeout_ms: Some(10_000), +}; +let mut request = build_request(&policy, None)?; +request.set_script("echo hello"); + +let mut proc = spawn_sandbox(request)?; +let mut stdout = proc.take_stdout().unwrap(); +let mut out = String::new(); +stdout.read_to_string(&mut out)?; // "hello\n" +let outcome = proc.wait()?; // drains/discards any untaken stream +assert_eq!(outcome, WaitOutcome::Exited(0)); +# Ok::<(), Box>(()) +``` + +[`build_request`] is the Rust port of the SDK's `createConfigFromPolicy`. It +resolves the host's containment backend (Seatbelt on macOS, Bubblewrap on +Linux, ProcessContainer on Windows) and mirrors the SDK's field mapping and +network validation, building the same wire config internally and running it +through the shared parser. The returned [`SandboxRequest`] has an empty +command line — set the command with [`SandboxRequest::set_script`] (and any +working directory / env) before spawning. + +Filesystem-policy discovery helpers (ports of the SDK's `policy.ts`) are also +available to feed a policy: [`available_tools_policy`] (PATH + tool/SDK env +dirs), [`user_profile_policy`], and [`temporary_files_policy`]. + +[`platform_support`] is the Rust port of `getPlatformSupport` — reports host +support and the available containment backends. + +## Live stdio + kill (streaming) + +[`spawn_sandbox`] returns a [`Sandbox`] you can drive +while it runs — persistent bidirectional stdio plus termination. No pty is +allocated; the streams are ordinary pipes. + +```rust,no_run +use std::io::{Read, Write}; +use mxc_sdk::{build_request, spawn_sandbox, SandboxPolicy, WaitOutcome}; + +let policy = SandboxPolicy { + version: "0.7.0-alpha".to_string(), + filesystem: None, + network: None, + ui: None, + timeout_ms: None, +}; +let mut request = build_request(&policy, None)?; +request.set_script("cat"); // echoes stdin until EOF + +let mut proc = spawn_sandbox(request)?; +let mut stdin = proc.take_stdin().unwrap(); +let mut stdout = proc.take_stdout().unwrap(); + +stdin.write_all(b"hello\n")?; +drop(stdin); // close -> child sees EOF +let mut out = String::new(); +stdout.read_to_string(&mut out)?; // "hello\n" + +let outcome = proc.wait()?; // any untaken stream is drained and discarded +assert_eq!(outcome, WaitOutcome::Exited(0)); +# Ok::<(), Box>(()) +``` + +The handle is modelled on [`std::process::Child`]: + +- `take_stdin()` → `Box`, `take_stdout()` / `take_stderr()` + → `Box` (drive them yourself; you own draining any stream + you take, to avoid the child blocking on a full pipe). +- `id()` returns the child's OS process id, for external monitoring or a + caller-driven process-tree kill. +- `try_wait()` for a non-blocking exit check. +- `kill()` terminates the sandboxed process **and its descendants** (a + process-tree kill): on Unix the child leads its own process group and the + whole group is signalled (an immediate `SIGKILL`, no graceful `SIGTERM`); + on Windows the child's job object is terminated. +- `wait()` blocks until exit (honouring `scriptTimeout`, where `0` waits + forever), drains and discards any **untaken** stdout/stderr so the child + can't block on a full pipe, and returns a `WaitOutcome` — + `Exited(code)` or `TimedOut` if the timeout elapses (`Err` is reserved for an + actual OS/wait failure). +- `wait_with_output()` consumes the handle and returns an `Output` with the + `WaitOutcome` plus the captured `stdout`/`stderr` — it drains both streams + concurrently for you, the safe alternative to `take_stdout()` + `take_stderr()` + (reading one to EOF before the other can deadlock an output-heavy child). +- `stdout_closer()` / `stderr_closer()` → `Option`: a + closer that makes an in-flight or subsequent read on the taken stream return + EOF promptly **without** killing the child — for abandoning a stream a + backgrounded descendant is holding open past the foreground command's exit (a + plain `kill()` would also take that descendant down). Returns `None` for + non-streamed stdio. + +Streaming is implemented for **Seatbelt (macOS)**, **Bubblewrap (Linux)**, and +**Windows ProcessContainer (AppContainer + BaseContainer)** — i.e. every +backend the library supports. + +> **Windows note:** streaming does not use the AppContainer-BFS / +> AppContainer-DACL fallback. Experimental / newer-schema configs that select +> BaseContainer require the native BaseContainer API; on a host without it, +> `spawn_sandbox` fails closed with a clear error rather than +> falling back to an AppContainer tier. + +## Supported backends + +The backend is chosen by the `containment` field in the request (or the host +default): + +| Host | Backend(s) | +|---------|--------------------------------------------------------| +| Linux | Bubblewrap | +| macOS | Seatbelt | +| Windows | ProcessContainer (AppContainer + BaseContainer) | + +Any other backend (Windows Sandbox, IsolationSession, MicroVM, Hyperlight, +WSLC, LXC) returns an [`Error`] with [`ErrorCode::UnsupportedContainment`]; drive the standalone +executor binaries for those. + +## No pty + +The child's stdio is always wired to ordinary pipes — the library never +allocates a pty (the executor binaries, by contrast, stream live: LXC via a +pty, Seatbelt/Bubblewrap/AppContainer by inheriting the executor's stdio +directly — a TTY when the executor has one). Output the caller doesn't +take is drained and discarded by `wait()`. + +## Relationship to the executor binaries + +The `wxc-exec`, `lxc-exec`, and `mxc-exec-mac` binaries do not depend on this +crate. It reuses the same backend crates they do, but selects between them +directly (no BFS/DACL `dispatch_with_fallback`) and spawns its own streaming +handles. diff --git a/src/core/mxc-sdk/src/dispatch.rs b/src/core/mxc-sdk/src/dispatch.rs new file mode 100644 index 000000000..7cacb9e4b --- /dev/null +++ b/src/core/mxc-sdk/src/dispatch.rs @@ -0,0 +1,286 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Streaming backend dispatch for the `mxc-sdk` library. +//! +//! Spawns the right [`SandboxProcess`] for the request's containment backend. +//! It lives here — rather than in `wxc_common` — because constructing a +//! backend runner requires depending on the `backends/*` crates, and +//! `wxc_common` must not (it is the cross-platform foundation those backends +//! build on). +//! +//! Only the backends the `mxc-sdk` library officially supports are handled here: +//! ProcessContainer (Windows AppContainer / BaseContainer fallback), +//! Bubblewrap (Linux), and Seatbelt (macOS). Every other backend — including +//! the experimental ones (Windows Sandbox, IsolationSession, MicroVM, +//! Hyperlight, WSLC) and LXC (no streaming path suitable for the library) — +//! returns [`MxcError::unsupported_containment`]; callers that need those must +//! drive the standalone executor binaries. +//! +//! **Provisional.** This in-crate backend selection is a temporary home. A +//! follow-up will introduce a dedicated `mxc` engine crate that both `mxc-sdk` +//! and the executor binaries call into; the selection/spawn logic here — and the +//! host probing in `platform.rs` — moves there, leaving `mxc-sdk` a thin +//! streaming wrapper over the engine. + +use wxc_common::logger::Logger; +use wxc_common::models::{ContainmentBackend, ExecutionRequest, ScriptResponse}; +use wxc_common::mxc_error::MxcError; +use wxc_common::sandbox_process::SandboxProcess; + +/// `Err` when the host OS has no MXC sandbox backend. Checked before backend +/// selection so an unsupported platform reports a clear message rather than a +/// backend-specific one (the default/abstract intent resolves to +/// ProcessContainer on non-Linux/macOS hosts). +fn ensure_host_supported() -> Result<(), MxcError> { + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + { + Ok(()) + } + #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] + { + Err(MxcError::unsupported_containment( + "the mxc-sdk library has no sandbox backend for this host OS \ + (supported: Windows, Linux, macOS)", + )) + } +} + +// --------------------------------------------------------------------------- +// Streaming (handle-based) spawn +// --------------------------------------------------------------------------- + +/// Spawn a [`SandboxProcess`] handle for `request` on the current host. +/// +/// Spawns the sandboxed process with piped stdio and returns a handle the +/// caller can write to, read from, wait on, and kill. Backends without a +/// streaming implementation return [`MxcError::unsupported_containment`]. +pub fn spawn_runner( + request: &ExecutionRequest, + logger: &mut Logger, +) -> Result, MxcError> { + ensure_host_supported()?; + // `dry_run` means "validate, don't execute" — there is no process to + // stream, so reject it rather than silently ignoring it. + if request.dry_run { + return Err(MxcError::malformed_request( + "dry_run is not supported for streaming spawns", + )); + } + match &request.containment { + ContainmentBackend::Seatbelt => spawn_seatbelt(request, logger), + ContainmentBackend::Bubblewrap => spawn_bubblewrap(request, logger), + ContainmentBackend::ProcessContainer => spawn_process_container(request, logger), + other => Err(MxcError::unsupported_containment(format!( + "the mxc-sdk library does not yet support streaming for the '{}' backend", + other.wire_name() + ))), + } +} + +/// Map a backend's `spawn` failure `ScriptResponse` to an +/// [`MxcError`], preserving the `BackendUnavailable` phase (so callers can fall +/// back to a lower tier) and folding any `extended_error` detail into the +/// message — rather than flattening everything to a generic `BackendError`. +fn map_spawn_error(resp: ScriptResponse) -> MxcError { + use wxc_common::models::FailurePhase; + + let mut message = resp.error_message; + if !resp.extended_error.is_empty() { + if message.is_empty() { + message = resp.extended_error; + } else { + message = format!("{message} ({})", resp.extended_error); + } + } + match resp.failure_phase { + FailurePhase::BackendUnavailable => MxcError::backend_unavailable(message), + _ => MxcError::backend_error(message), + } +} + +#[cfg(target_os = "linux")] +fn spawn_bubblewrap( + request: &ExecutionRequest, + logger: &mut Logger, +) -> Result, MxcError> { + use wxc_common::sandbox_process::{SandboxBackend, StdioMode}; + let mut runner = bwrap_common::bwrap_runner::BubblewrapScriptRunner::new(); + runner + .spawn(request, logger, StdioMode::Pipes) + .map_err(map_spawn_error) +} + +#[cfg(not(target_os = "linux"))] +fn spawn_bubblewrap( + _request: &ExecutionRequest, + _logger: &mut Logger, +) -> Result, MxcError> { + Err(MxcError::unsupported_containment( + "Bubblewrap is only available on Linux", + )) +} + +#[cfg(target_os = "macos")] +fn spawn_seatbelt( + request: &ExecutionRequest, + logger: &mut Logger, +) -> Result, MxcError> { + use wxc_common::sandbox_process::{SandboxBackend, StdioMode}; + let mut runner = seatbelt_common::seatbelt_runner::SeatbeltScriptRunner::new(); + runner + .spawn(request, logger, StdioMode::Pipes) + .map_err(map_spawn_error) +} + +#[cfg(not(target_os = "macos"))] +fn spawn_seatbelt( + _request: &ExecutionRequest, + _logger: &mut Logger, +) -> Result, MxcError> { + Err(MxcError::unsupported_containment( + "Seatbelt is only available on macOS", + )) +} + +#[cfg(target_os = "windows")] +fn spawn_process_container( + request: &ExecutionRequest, + logger: &mut Logger, +) -> Result, MxcError> { + use appcontainer_common::appcontainer_runner::AppContainerScriptRunner; + use wxc_common::config_parser::is_base_container_version; + use wxc_common::sandbox_process::{SandboxBackend, StdioMode}; + + // The AppContainer fast path vs the native BaseContainer (OS sandbox API): + // unlike the executor binaries' run-to-completion fallback, streaming does + // NOT route through `dispatch_with_fallback` — there is no AppContainer-BFS + // / AppContainer-DACL fallback for streaming. + // + // Why: `dispatch_with_fallback` yields a run-to-completion + // `Box` plus a `DaclManager` guard, neither of which + // fits the streaming handle (the DACL tier would require the returned + // `SandboxProcess` to own the guard so ACE restore outlives the child). + // + // Consequence (intentional, fail-closed): an experimental / newer-schema + // config on a host that lacks the native BaseContainer API fails here with + // a clear "BaseContainer API unavailable" error from + // `BaseContainerRunner`'s validation, whereas the binaries' fallback would + // drop to an AppContainer tier. Streaming therefore requires the native + // BaseContainer API for those configs. + let version_implies_base_container = is_base_container_version(&request.schema_version); + if request.experimental_enabled || version_implies_base_container { + let mut runner = appcontainer_common::base_container_runner::BaseContainerRunner::new(); + return runner + .spawn(request, logger, StdioMode::Pipes) + .map_err(map_spawn_error); + } + + let mut runner = AppContainerScriptRunner::new(); + runner + .spawn(request, logger, StdioMode::Pipes) + .map_err(map_spawn_error) +} + +#[cfg(not(target_os = "windows"))] +fn spawn_process_container( + _request: &ExecutionRequest, + _logger: &mut Logger, +) -> Result, MxcError> { + Err(MxcError::unsupported_containment( + "ProcessContainer (AppContainer / BaseContainer) is only available on Windows", + )) +} + +#[cfg(test)] +mod tests { + use super::{ensure_host_supported, spawn_runner}; + use crate::policy::{build_request, SandboxPolicy}; + use wxc_common::logger::{Logger, Mode}; + use wxc_common::models::ContainmentBackend; + use wxc_common::mxc_error::MxcErrorCode; + + fn minimal_policy() -> SandboxPolicy { + SandboxPolicy { + version: "0.7.0-alpha".to_string(), + filesystem: None, + network: None, + ui: None, + timeout_ms: None, + } + } + + #[test] + fn streaming_rejects_dry_run() { + // `dry_run` ("validate, don't execute") has no process to stream, so the + // streaming spawn rejects it. The public `SandboxRequest` can't set it, + // so drive the dispatch directly with the internal model. + let mut request = build_request(&minimal_policy(), None).expect("build_request"); + request.inner.dry_run = true; + let mut logger = Logger::new(Mode::Buffer); + let err = match spawn_runner(&request.inner, &mut logger) { + Ok(_) => panic!("dry_run must be rejected"), + Err(e) => e, + }; + assert_eq!(err.code, MxcErrorCode::MalformedRequest); + } + + #[test] + fn streaming_rejects_unsupported_containment() { + // LXC has no streaming path in the library; selecting it must surface a + // clear `UnsupportedContainment` rather than spawning. The public + // `SandboxRequest` can't choose a backend, so drive dispatch with the + // internal model. + let mut request = build_request(&minimal_policy(), None).expect("build_request"); + request.inner.containment = ContainmentBackend::Lxc; + let mut logger = Logger::new(Mode::Buffer); + let err = match spawn_runner(&request.inner, &mut logger) { + Ok(_) => panic!("LXC must be rejected"), + Err(e) => e, + }; + assert_eq!(err.code, MxcErrorCode::UnsupportedContainment); + assert!(err.message.contains("lxc"), "got: {}", err.message); + } + + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + #[test] + fn host_support_ok_on_supported_platforms() { + // The three platforms the library supports must all pass the host gate + // `spawn_runner` checks before backend selection; this guards against a + // regression in the `cfg` list dropping one of them. + assert!(ensure_host_supported().is_ok()); + } + + #[cfg(target_os = "macos")] + #[test] + fn streaming_rejects_gui_access() { + // A windowed (guiAccess) app needs inherited stdio, so it can't stream + // over pipes — the backend must reject it rather than drop the GUI cap. + let policy = SandboxPolicy { + version: "0.7.0-alpha".to_string(), + filesystem: Some(crate::policy::FilesystemSection { + readwrite_paths: vec!["/tmp".to_string()], + readonly_paths: vec![], + denied_paths: vec![], + clear_policy_on_exit: None, + }), + network: None, + ui: None, + timeout_ms: None, + }; + let mut request = build_request(&policy, None).expect("build_request"); + request.set_script("echo hi"); + request + .inner + .seatbelt + .as_mut() + .expect("seatbelt config on macOS") + .gui_access = true; + let mut logger = Logger::new(Mode::Buffer); + let err = match spawn_runner(&request.inner, &mut logger) { + Ok(_) => panic!("guiAccess must be rejected"), + Err(e) => e, + }; + assert!(err.message.contains("guiAccess"), "got: {}", err.message); + } +} diff --git a/src/core/mxc-sdk/src/error.rs b/src/core/mxc-sdk/src/error.rs new file mode 100644 index 000000000..02a3ffddc --- /dev/null +++ b/src/core/mxc-sdk/src/error.rs @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The SDK's own error type — a crate-owned facade over the internal +//! `wxc_common` error, so the public API never exposes the foundation crate. + +use wxc_common::mxc_error::{MxcError, MxcErrorCode}; + +/// Closed set of error codes the SDK can return. Mirrors the wire-format codes +/// (serialised as snake_case strings) one-for-one. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ErrorCode { + MalformedRequest, + UnsupportedContainment, + UnsupportedPhase, + BackendUnavailable, + MalformedId, + StaleId, + NotProvisioned, + NotStarted, + AlreadyStarted, + AlreadyStopped, + PolicyValidation, + BackendError, +} + +impl ErrorCode { + /// The wire-format (snake_case) string for this code. + pub fn as_str(self) -> &'static str { + match self { + Self::MalformedRequest => "malformed_request", + Self::UnsupportedContainment => "unsupported_containment", + Self::UnsupportedPhase => "unsupported_phase", + Self::BackendUnavailable => "backend_unavailable", + Self::MalformedId => "malformed_id", + Self::StaleId => "stale_id", + Self::NotProvisioned => "not_provisioned", + Self::NotStarted => "not_started", + Self::AlreadyStarted => "already_started", + Self::AlreadyStopped => "already_stopped", + Self::PolicyValidation => "policy_validation", + Self::BackendError => "backend_error", + } + } +} + +impl std::fmt::Display for ErrorCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl From for ErrorCode { + fn from(code: MxcErrorCode) -> Self { + match code { + MxcErrorCode::MalformedRequest => Self::MalformedRequest, + MxcErrorCode::UnsupportedContainment => Self::UnsupportedContainment, + MxcErrorCode::UnsupportedPhase => Self::UnsupportedPhase, + MxcErrorCode::BackendUnavailable => Self::BackendUnavailable, + MxcErrorCode::MalformedId => Self::MalformedId, + MxcErrorCode::StaleId => Self::StaleId, + MxcErrorCode::NotProvisioned => Self::NotProvisioned, + MxcErrorCode::NotStarted => Self::NotStarted, + MxcErrorCode::AlreadyStarted => Self::AlreadyStarted, + MxcErrorCode::AlreadyStopped => Self::AlreadyStopped, + MxcErrorCode::PolicyValidation => Self::PolicyValidation, + MxcErrorCode::BackendError => Self::BackendError, + } + } +} + +/// An error returned by the SDK's fallible operations +/// ([`build_request`](crate::build_request) / [`spawn_sandbox`](crate::spawn_sandbox)). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Error { + /// The closed error code. + pub code: ErrorCode, + /// A human-readable message. + pub message: String, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}: {}", self.code, self.message) + } +} + +impl std::error::Error for Error {} + +impl From for Error { + fn from(error: MxcError) -> Self { + Self { + code: error.code.into(), + message: error.message, + } + } +} diff --git a/src/core/mxc-sdk/src/lib.rs b/src/core/mxc-sdk/src/lib.rs new file mode 100644 index 000000000..50490f054 --- /dev/null +++ b/src/core/mxc-sdk/src/lib.rs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! `mxc-sdk` — an importable library for starting MXC sandboxes in-process. +//! +//! Build a [`SandboxRequest`] from a [`SandboxPolicy`] with [`build_request`], +//! then hand it to [`spawn_sandbox`]: +//! it selects the right containment backend for the host and spawns the +//! sandboxed process **without ever allocating a pty**, returning a +//! [`Sandbox`] handle for live bidirectional stdio and termination. +//! +//! ```no_run +//! use mxc_sdk::{build_request, spawn_sandbox, SandboxPolicy, WaitOutcome}; +//! +//! // Turn a policy into a request, fill in the command, and spawn it. +//! let policy = SandboxPolicy { +//! version: "0.7.0-alpha".to_string(), +//! filesystem: None, +//! network: None, +//! ui: None, +//! timeout_ms: None, +//! }; +//! let mut request = build_request(&policy, None)?; +//! request.set_script("echo hi"); +//! let mut proc = spawn_sandbox(request)?; +//! match proc.wait()? { +//! WaitOutcome::Exited(code) => println!("exit={code}"), +//! WaitOutcome::TimedOut => println!("timed out"), +//! } +//! # Ok::<(), Box>(()) +//! ``` +//! +//! ## Backend support +//! +//! The selected backend is driven by the `containment` field in the request +//! (or the host default). The library supports Bubblewrap (Linux), Seatbelt +//! (macOS), and ProcessContainer — AppContainer and BaseContainer — +//! (Windows). Other backends return an [`Error`] with +//! [`ErrorCode::UnsupportedContainment`]. +//! +//! ## No pty +//! +//! The child's stdio is always wired to ordinary pipes — the library never +//! allocates a pty. Stream the handle's `take_stdout`/`take_stderr`, or let +//! [`wait`](Sandbox::wait) drain and discard any untaken stream. + +mod dispatch; +mod error; +mod platform; +pub mod policy; +mod sandbox; + +use dispatch::spawn_runner; +pub use platform::{platform_support, PlatformSupport}; +pub use policy::{ + available_tools_policy, build_request, temporary_files_policy, user_profile_policy, + FilesystemPolicyResult, SandboxPolicy, SandboxRequest, +}; + +pub use error::{Error, ErrorCode}; +pub use sandbox::{Output, Sandbox, StreamCloser, WaitOutcome}; + +use wxc_common::logger::{Logger, Mode}; + +/// Spawn a sandbox from a [`SandboxRequest`] built by [`build_request`] (with +/// the command, and any working directory / env, filled in). +/// +/// Returns a [`Sandbox`] handle for live bidirectional stdio and termination; +/// no pty is allocated. Any stdout/stderr stream the caller does not `take_*` is +/// drained and discarded by [`wait`](Sandbox::wait). +pub fn spawn_sandbox(request: SandboxRequest) -> Result { + let mut logger = Logger::new(Mode::Buffer); + spawn_runner(&request.inner, &mut logger) + .map(Sandbox::new) + .map_err(Error::from) +} diff --git a/src/core/mxc-sdk/src/platform.rs b/src/core/mxc-sdk/src/platform.rs new file mode 100644 index 000000000..5ea7be621 --- /dev/null +++ b/src/core/mxc-sdk/src/platform.rs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Host platform support detection — the Rust port of the SDK's +//! `getPlatformSupport`. +//! +//! Reports whether MXC can run on the current host and which containment +//! backends are available. This lets callers stop depending on the TypeScript +//! SDK for platform discovery. +//! +//! **Provisional.** Like the backend dispatch in `dispatch.rs`, this host +//! probing is a temporary home; it moves to the future `mxc` engine crate that +//! both `mxc-sdk` and the executor binaries will share. + +/// Platform support information — the Rust analogue of the SDK +/// `PlatformSupport` type. +#[derive(Debug, Clone, Default)] +pub struct PlatformSupport { + /// Whether MXC is supported on the current host. + pub is_supported: bool, + /// Why the platform is unsupported, when `is_supported` is false. + pub reason: Option, + /// Containment backends available on this host, by wire name + /// (e.g. `"seatbelt"`, `"bubblewrap"`, `"processcontainer"`). + pub available_methods: Vec, +} + +/// Detect MXC support on the current host. +/// +/// Mirrors the SDK's `getPlatformSupport`, restricted to the backends the +/// `mxc-sdk` library can actually run. On Windows the isolation tier and UI +/// capabilities come from the in-process fallback probe rather than a +/// `wxc-exec --probe` subprocess. +pub fn platform_support() -> PlatformSupport { + #[cfg(target_os = "macos")] + { + if std::path::Path::new("/usr/bin/sandbox-exec").exists() { + PlatformSupport { + is_supported: true, + available_methods: vec!["seatbelt".to_string()], + ..Default::default() + } + } else { + PlatformSupport { + reason: Some( + "/usr/bin/sandbox-exec not found; macOS install is incomplete".to_string(), + ), + ..Default::default() + } + } + } + + #[cfg(target_os = "linux")] + { + if command_succeeds("bwrap", &["--version"]) { + PlatformSupport { + is_supported: true, + available_methods: vec!["bubblewrap".to_string()], + ..Default::default() + } + } else { + PlatformSupport { + reason: Some("Bubblewrap is not available on this system".to_string()), + ..Default::default() + } + } + } + + #[cfg(target_os = "windows")] + { + PlatformSupport { + is_supported: true, + available_methods: vec!["processcontainer".to_string()], + ..Default::default() + } + } + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + PlatformSupport { + reason: Some("MXC is not supported on this platform".to_string()), + ..Default::default() + } + } +} + +/// Returns true when `program args...` exits successfully — used to probe for +/// the presence of `bwrap` on Linux. +#[cfg(target_os = "linux")] +fn command_succeeds(program: &str, args: &[&str]) -> bool { + use std::process::{Command, Stdio}; + Command::new(program) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} diff --git a/src/core/mxc-sdk/src/policy.rs b/src/core/mxc-sdk/src/policy.rs new file mode 100644 index 000000000..f5d0b69a1 --- /dev/null +++ b/src/core/mxc-sdk/src/policy.rs @@ -0,0 +1,1092 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Policy discovery and config building — the Rust port of the SDK's +//! `policy.ts` helpers and `createConfigFromPolicy`. +//! +//! - [`available_tools_policy`], [`user_profile_policy`], and +//! [`temporary_files_policy`] enumerate the host environment to discover +//! tool/SDK/profile/temp directories as filesystem-policy fragments. +//! - [`SandboxPolicy`] mirrors the SDK's cross-platform policy type, and +//! [`build_request`] maps it to an [`ExecutionRequest`] for the backends the +//! crate supports (Seatbelt, Bubblewrap, ProcessContainer) — so callers no +//! longer need the TypeScript SDK to build a spawnable config. + +use std::borrow::Cow; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use wxc_common::logger::{Logger, Mode}; +use wxc_common::models::ExecutionRequest; +use wxc_common::mxc_error::MxcError; + +// --------------------------------------------------------------------------- +// Filesystem policy discovery +// --------------------------------------------------------------------------- + +/// A composable fragment of filesystem policy. Callers merge one or more into +/// a [`SandboxPolicy`]'s filesystem section. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct FilesystemPolicyResult { + /// Paths to grant read-only access inside the sandbox. + pub readonly_paths: Vec, + /// Paths to grant read-write access inside the sandbox. + pub readwrite_paths: Vec, +} + +/// Well-known tool/SDK environment variables and how to extract directories +/// from each. Mirrors the SDK's `KNOWN_ENV_VARS`. The `bool` is whether the +/// value is a path-list (split on the platform separator) vs a single path. +const KNOWN_ENV_VARS: &[(&str, bool)] = &[ + ("PYTHONPATH", true), + ("PYTHONHOME", false), + ("VCINSTALLDIR", false), + ("VSINSTALLDIR", false), + ("PSModulePath", true), + ("VCPKG_ROOT", false), + ("GOPATH", false), + ("GOROOT", false), + ("CARGO_HOME", false), + ("RUSTUP_HOME", false), + ("JAVA_HOME", false), + ("NVM_HOME", false), + ("NVM_SYMLINK", false), + ("NODE_PATH", true), + ("DOTNET_ROOT", false), + ("CONDA_PREFIX", false), + ("LD_LIBRARY_PATH", true), + ("VIRTUAL_ENV", false), + ("PYENV_ROOT", false), +]; + +fn is_windows() -> bool { + cfg!(target_os = "windows") +} + +/// Split a path-list value on the platform separator (`;` on Windows, `:` +/// elsewhere), dropping empty entries. +fn split_path_list(value: &str) -> Vec { + let sep = if is_windows() { ';' } else { ':' }; + value + .split(sep) + .filter(|p| !p.is_empty()) + .map(str::to_string) + .collect() +} + +fn single_path(value: &str) -> Vec { + let trimmed = value.trim(); + if trimmed.is_empty() { + Vec::new() + } else { + vec![trimmed.to_string()] + } +} + +fn directory_exists(dir: &str) -> bool { + std::fs::metadata(dir).map(|m| m.is_dir()).unwrap_or(false) +} + +/// Join `base` with successive path segments, returning an owned `String`. +/// Windows policy paths are always valid UTF-16/UTF-8, so the lossy conversion +/// never actually substitutes characters in practice. +fn join_str(base: &str, segments: &[&str]) -> String { + let mut path = PathBuf::from(base); + for segment in segments { + path.push(segment); + } + path.to_string_lossy().into_owned() +} + +/// Resolve a path to absolute, lexically-normalized form — the equivalent of +/// the SDK's `path.resolve`. Purely lexical (no filesystem access, no symlink +/// resolution): a relative path is joined with the cwd, then `.`/`..` segments +/// are collapsed. Crucially it does *not* canonicalize, so on Windows it keeps +/// the plain `C:\...` form (no `\\?\` verbatim prefix) — otherwise +/// [`is_system_critical_path`]'s `C:\Windows` prefix check would never match. +fn resolve_path(p: &str) -> String { + let path = Path::new(p); + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + match std::env::current_dir() { + Ok(cwd) => cwd.join(path), + Err(_) => path.to_path_buf(), + } + }; + normalize_lexically(&absolute) + .to_string_lossy() + .into_owned() +} + +/// Collapse `.`/`..` segments without touching the filesystem, preserving the +/// path prefix/root (the well-known lexical-normalize pattern). +fn normalize_lexically(path: &Path) -> PathBuf { + use std::path::Component; + let mut components = path.components().peekable(); + let mut out = if let Some(c @ Component::Prefix(..)) = components.peek().copied() { + components.next(); + PathBuf::from(c.as_os_str()) + } else { + PathBuf::new() + }; + for component in components { + match component { + Component::Prefix(..) => unreachable!("prefix only appears first"), + Component::RootDir => out.push(component.as_os_str()), + Component::CurDir => {} + Component::ParentDir => match out.components().next_back() { + // Pop a real directory name. + Some(Component::Normal(_)) => { + out.pop(); + } + // At a root/prefix: `..` can't go above it — ignore the segment + // (so `/a/../../b` stays `/b`, and `C:\..` stays `C:\`). + Some(Component::RootDir | Component::Prefix(..)) => {} + // Relative path (empty or already leading with `..`): preserve. + _ => out.push(component.as_os_str()), + }, + Component::Normal(c) => out.push(c), + } + } + out +} + +/// Deduplicate resolved paths, case-insensitively on Windows. +fn deduplicate_paths(paths: &[String]) -> Vec { + let windows = is_windows(); + let mut seen: HashSet = HashSet::new(); + let mut out = Vec::new(); + for p in paths { + let resolved = resolve_path(p); + let key = if windows { + resolved.to_lowercase() + } else { + resolved.clone() + }; + if seen.insert(key) { + out.push(resolved); + } + } + out +} + +/// Whether `dir` is under a system-critical location that must not be exposed. +fn is_system_critical_path(dir: &str) -> bool { + let normalized = resolve_path(dir); + if is_windows() { + // A set-but-empty `WINDIR` must not disable the filter: treat empty as + // unset and fall back (the same `WINDIR` handling `powershell_policy` + // uses). + let win_dir = std::env::var("WINDIR") + .ok() + .or_else(|| std::env::var("windir").ok()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "C:\\Windows".to_string()) + .to_lowercase(); + // Strip a verbatim (`\\?\`, `\\?\UNC\`) prefix so a path supplied in + // that form still matches the plain `C:\Windows` comparison. + let n = normalized.to_lowercase(); + let n = n + .strip_prefix(r"\\?\unc\") + .or_else(|| n.strip_prefix(r"\\?\")) + .unwrap_or(&n); + return n == win_dir || n.starts_with(&format!("{win_dir}\\")); + } + const CRITICAL: &[&str] = &[ + "/bin", + "/sbin", + "/usr/bin", + "/usr/sbin", + "/boot", + "/proc", + "/sys", + "/dev", + ]; + CRITICAL + .iter() + .any(|cp| normalized == *cp || normalized.starts_with(&format!("{cp}/"))) +} + +fn env_get<'a>(env: &'a [(String, String)], name: &str) -> Option<&'a str> { + // Windows environment variable names are case-insensitive (matching the OS + // and Node's `process.env`, which the TS SDK relies on); Unix names are + // case-sensitive. + env.iter() + .find(|(k, _)| { + if cfg!(windows) { + k.eq_ignore_ascii_case(name) + } else { + k == name + } + }) + .map(|(_, v)| v.as_str()) +} + +/// Borrow the caller-supplied env, or snapshot the process environment when +/// `None`. +fn env_or_process(env: Option<&[(String, String)]>) -> Cow<'_, [(String, String)]> { + match env { + Some(e) => Cow::Borrowed(e), + None => Cow::Owned(std::env::vars().collect()), + } +} + +/// PowerShell-specific policy: when `pwsh.exe` is found on `path_dirs` +/// (Windows only), grant the system-drive root (`C:\`) read-only — `pwsh.exe` +/// enumerates the drive root on startup — plus the PSReadLine history directory +/// read-write so the module can persist command history. +/// +/// Mirrors the SDK's `getPowerShellPolicy`. The system drive is read from the +/// process environment (`SystemDrive`, defaulting to `C:`); the user-scoped +/// `USERPROFILE` comes from the passed-in `env`. +/// +/// On non-Windows, or when `pwsh.exe` is not on `path_dirs`, returns an empty +/// policy. +fn powershell_policy(path_dirs: &[String], env: &[(String, String)]) -> FilesystemPolicyResult { + if !is_windows() { + return FilesystemPolicyResult::default(); + } + + let pwsh_found = path_dirs + .iter() + .any(|dir| Path::new(dir).join("pwsh.exe").exists()); + if !pwsh_found { + return FilesystemPolicyResult::default(); + } + + let system_drive = std::env::var("SystemDrive") + .ok() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "C:".to_string()); + let readonly_paths = vec![format!("{system_drive}\\")]; + + let mut readwrite_paths: Vec = Vec::new(); + if let Some(user_profile) = env_get(env, "USERPROFILE") { + // PSReadLine command-history directory (read-write). + readwrite_paths.push(join_str( + user_profile, + &[ + "AppData", + "Roaming", + "Microsoft", + "Windows", + "PowerShell", + "PSReadLine", + ], + )); + } + + FilesystemPolicyResult { + readonly_paths, + readwrite_paths, + } +} + +/// Discover tool and SDK directories from `env` (defaults to the process +/// environment) as read-only policy paths. +/// +/// Reads `PATH` plus a registry of well-known tool/SDK variables, then filters +/// out non-existent and system-critical directories, and adds PowerShell paths +/// when `pwsh.exe` is on `PATH`. The Rust port of `getAvailableToolsPolicy`. +/// (The SDK's `processcontainer` AAP-ACL filter is Windows-runtime-specific and +/// is applied server-side; it is not replicated here.) +pub fn available_tools_policy(env: Option<&[(String, String)]>) -> FilesystemPolicyResult { + let env = env_or_process(env); + let env: &[(String, String)] = &env; + + let mut collected = Vec::new(); + let path_value = env_get(env, "PATH") + .or_else(|| env_get(env, "Path")) + .unwrap_or(""); + let path_dirs = split_path_list(path_value); + collected.extend(path_dirs.iter().cloned()); + + for (name, is_list) in KNOWN_ENV_VARS { + if let Some(value) = env_get(env, name) { + let extracted = if *is_list { + split_path_list(value) + } else { + single_path(value) + }; + collected.extend(extracted); + } + } + + let filtered: Vec = deduplicate_paths(&collected) + .into_iter() + .filter(|dir| directory_exists(dir) && !is_system_critical_path(dir)) + .collect(); + + let pwsh = powershell_policy(&path_dirs, env); + + let mut readonly = filtered; + readonly.extend(pwsh.readonly_paths); + + FilesystemPolicyResult { + readonly_paths: deduplicate_paths(&readonly), + readwrite_paths: deduplicate_paths(&pwsh.readwrite_paths), + } +} + +/// Read-only policy for standard user-profile application data locations. +/// +/// Windows: immediate subdirectories of `%LOCALAPPDATA%\Programs`. Other +/// platforms: `~/.local/bin` and `~/.local/lib`. The Rust port of +/// `getUserProfilePolicy`. +pub fn user_profile_policy() -> FilesystemPolicyResult { + let mut readonly_paths = Vec::new(); + + if is_windows() { + if let Ok(local_app_data) = std::env::var("LOCALAPPDATA") { + if directory_exists(&local_app_data) { + let programs = Path::new(&local_app_data).join("Programs"); + if let Ok(entries) = std::fs::read_dir(&programs) { + for entry in entries.flatten() { + if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + readonly_paths.push(entry.path().to_string_lossy().into_owned()); + } + } + } + } + } + } else if let Ok(home) = std::env::var("HOME") { + for sub in [".local/bin", ".local/lib"] { + let dir = Path::new(&home).join(sub); + let dir = dir.to_string_lossy().into_owned(); + if directory_exists(&dir) { + readonly_paths.push(dir); + } + } + } + + FilesystemPolicyResult { + readonly_paths, + readwrite_paths: Vec::new(), + } +} + +/// Read-write policy for the host temporary directory. +/// +/// Windows: `TEMP` or `TMP`. Other platforms: `TMPDIR` or `/tmp`. Returns an +/// empty fragment when the resolved directory does not exist. The Rust port of +/// `getTemporaryFilesPolicy`. +pub fn temporary_files_policy(env: Option<&[(String, String)]>) -> FilesystemPolicyResult { + let env = env_or_process(env); + let env: &[(String, String)] = &env; + + let temp_root = if is_windows() { + env_get(env, "TEMP").or_else(|| env_get(env, "TMP")) + } else { + Some(env_get(env, "TMPDIR").unwrap_or("/tmp")) + }; + + match temp_root { + Some(root) if directory_exists(root) => FilesystemPolicyResult { + readonly_paths: Vec::new(), + readwrite_paths: vec![root.to_string()], + }, + _ => FilesystemPolicyResult::default(), + } +} + +// --------------------------------------------------------------------------- +// SandboxPolicy -> ExecutionRequest +// --------------------------------------------------------------------------- + +/// 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")] +pub enum ClipboardPolicy { + /// No clipboard access. + #[default] + None, + /// Read-only clipboard access. + Read, + /// Write-only clipboard access. + Write, + /// Read and write clipboard access. + All, +} + +impl ClipboardPolicy { + /// Wire-format value accepted by the config parser. + fn wire(self) -> &'static str { + match self { + ClipboardPolicy::None => "none", + ClipboardPolicy::Read => "read", + ClipboardPolicy::Write => "write", + ClipboardPolicy::All => "all", + } + } +} + +/// Filesystem section of a [`SandboxPolicy`]. +#[derive(Debug, Clone, Default, serde::Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct FilesystemSection { + pub readwrite_paths: Vec, + pub readonly_paths: Vec, + pub denied_paths: Vec, + /// Clear the filesystem policy when the shell exits (default `true`). + pub clear_policy_on_exit: Option, +} + +/// Network proxy configuration, mirroring the SDK union type +/// `{ builtinTestServer: true } | { localhost: number } | { url: string }`. +#[derive(Debug, Clone)] +pub enum ProxySpec { + /// Route through the built-in test proxy server. + BuiltinTestServer, + /// Route through `127.0.0.1:`. + Localhost(u16), + /// Route through an explicit proxy URL. + Url(String), +} + +// Custom `Deserialize` matching the SDK's object union +// `{ builtinTestServer: true } | { localhost: number } | { url: string }`. +// serde's default derive can't express it, and an untagged enum would silently +// keep the first matching variant when several conflicting keys are present, so +// we parse all recognised modes and require exactly one — rejecting conflicts +// the way the shared wire-config parser does. +impl<'de> serde::Deserialize<'de> for ProxySpec { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(serde::Deserialize)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + struct Raw { + #[serde(default)] + builtin_test_server: Option, + #[serde(default)] + localhost: Option, + #[serde(default)] + url: Option, + } + let raw = Raw::deserialize(deserializer)?; + match (raw.builtin_test_server, raw.localhost, raw.url) { + (Some(true), None, None) => Ok(ProxySpec::BuiltinTestServer), + // The SDK union type is `{ builtinTestServer: true }`, so an explicit + // `false` is malformed. Reject it rather than silently selecting the + // (experimental, deliberately-permissive) built-in proxy — fail closed. + (Some(false), None, None) => Err(serde::de::Error::custom( + "network.proxy.builtinTestServer must be true; omit the proxy to disable it", + )), + (None, Some(port), None) => Ok(ProxySpec::Localhost(port)), + (None, None, Some(url)) => Ok(ProxySpec::Url(url)), + _ => Err(serde::de::Error::custom( + "network.proxy must set exactly one of builtinTestServer, localhost, or url", + )), + } + } +} + +/// Network section of a [`SandboxPolicy`]. All flags default to deny. +#[derive(Debug, Clone, Default, serde::Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct NetworkSection { + pub allow_outbound: bool, + pub allow_local_network: bool, + pub allowed_hosts: Vec, + pub blocked_hosts: Vec, + pub proxy: Option, +} + +/// UI section of a [`SandboxPolicy`]. All flags default to denied. +#[derive(Debug, Clone, Default, serde::Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct UiSection { + pub allow_windows: bool, + pub clipboard: ClipboardPolicy, + pub allow_input_injection: bool, +} + +/// Cross-platform sandbox policy — the Rust analogue of the SDK +/// `SandboxPolicy`. Describes *what* to restrict; omitted fields are +/// most-restrictive (default-deny). +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SandboxPolicy { + /// Policy/schema version (e.g. `"0.7.0-alpha"`). + pub version: String, + #[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, +} + +/// A spawnable sandbox request, built from a [`SandboxPolicy`] by +/// [`build_request`]. Fill in the command with +/// [`set_script`](Self::set_script) — and optionally a working +/// directory or environment — then hand it to +/// [`spawn_sandbox`](crate::spawn_sandbox). +/// +/// This is the SDK's own request type; the internal execution model it maps to +/// is an implementation detail callers don't depend on. +#[derive(Debug, Clone)] +pub struct SandboxRequest { + /// The internal execution model. `pub(crate)` so the SDK's own modules and + /// unit tests can map/inspect it, while it stays out of the public API. + pub(crate) inner: ExecutionRequest, +} + +impl SandboxRequest { + /// Set the command the sandbox runs — the `/bin/sh -c` body on Unix, the + /// command line on Windows. + /// + /// This is the raw command string, mapped to the same `script_code` the + /// executor binaries run, so it is interpreted exactly as the SDK's + /// `spawnSandbox(script)` / `process.commandLine` is — behavior is identical + /// across the SDK and this crate. + pub fn set_script(&mut self, script: impl Into) -> &mut Self { + self.inner.script_code = script.into(); + self + } + + /// Override the working directory the sandboxed child starts in. Left unset, + /// it defaults to the policy's resolution. + pub fn set_working_directory(&mut self, working_directory: impl Into) -> &mut Self { + self.inner.working_directory = working_directory.into(); + self + } + + /// Set the child's environment from `(key, value)` pairs. + /// + /// Each pair is stored as a `KEY=VALUE` entry — the same wire form the SDK's + /// env channel produces (`injectEnvIntoConfig` joins a `{ key: value }` map + /// the same way), so behavior is identical across the SDK and this crate. + /// Iteration order is preserved, so on a duplicate key the later entry wins, + /// matching the SDK. + pub fn set_env(&mut self, env: impl IntoIterator) -> &mut Self + where + K: Into, + V: Into, + { + self.inner.env = env + .into_iter() + .map(|(k, v)| { + let (k, v): (String, String) = (k.into(), v.into()); + format!("{k}={v}") + }) + .collect(); + self + } + + /// The Seatbelt (macOS) extra Mach service names the sandbox profile lets the + /// child look up. Empty when the request carries no Seatbelt config (i.e. a + /// non-Seatbelt backend). Read these — e.g. to union with your own — before + /// [`set_seatbelt_extra_mach_lookups`](Self::set_seatbelt_extra_mach_lookups). + pub fn seatbelt_extra_mach_lookups(&self) -> &[String] { + self.inner + .seatbelt + .as_ref() + .map_or(&[], |s| s.extra_mach_lookups.as_slice()) + } + + /// Set the Seatbelt (macOS) extra Mach service names the child may look up. + /// Creates a default Seatbelt config if the request carries none. + pub fn set_seatbelt_extra_mach_lookups(&mut self, lookups: Vec) -> &mut Self { + self.inner + .seatbelt + .get_or_insert_default() + .extra_mach_lookups = lookups; + self + } + + /// Allow (or deny) the Seatbelt-sandboxed (macOS) child access to the system + /// keychain. Creates a default Seatbelt config if the request carries none. + pub fn set_seatbelt_keychain_access(&mut self, allow: bool) -> &mut Self { + self.inner.seatbelt.get_or_insert_default().keychain_access = allow; + self + } +} + +/// Build a [`SandboxRequest`] from a [`SandboxPolicy`], resolving the host's +/// containment backend — the Rust port of the SDK's `createConfigFromPolicy`. +/// +/// The returned request has an empty command line; set the command with +/// [`SandboxRequest::set_script`] (and any working directory / env) before +/// streaming it via [`crate::spawn_sandbox`]. +/// +/// Mirrors the SDK field mapping and validation (network proxy/host-filtering +/// constraints) for the supported backends. Internally it builds the same +/// wire-format `ContainerConfig` the SDK emits and runs it through the shared +/// config parser, so validation and the wire→model mapping match production. +pub fn build_request( + policy: &SandboxPolicy, + container_name: Option<&str>, +) -> Result { + // The shared parser tolerates an empty schema version (treats it as + // "unset"), but the SDK requires it; reject it here for parity. + if policy.version.is_empty() { + return Err(MxcError::malformed_request("Policy version is required").into()); + } + let config = build_wire_config(policy, container_name)?; + + let mut logger = Logger::new(Mode::Buffer); + // Map the wire config straight to a request — no base64/file round-trip. + // The command line is intentionally empty here (the caller fills + // `script_code` before running), so tolerate a missing command. + let inner = wxc_common::config_parser::load_request_from_value(config, &mut logger, true) + .map_err(|e| MxcError::malformed_request(format!("failed to build request: {e}")))?; + Ok(SandboxRequest { inner }) +} + +/// Construct the wire-format `ContainerConfig` JSON value for the supported +/// backends, mirroring `createConfigFromPolicy` + the per-backend builders. +fn build_wire_config( + policy: &SandboxPolicy, + container_name: Option<&str>, +) -> Result { + use serde_json::json; + + let container_id = container_name + .map(str::to_string) + .unwrap_or_else(wxc_common::id::mint_random_token); + + let fs = policy.filesystem.clone().unwrap_or_default(); + let clear_policy = fs.clear_policy_on_exit.unwrap_or(true); + + let mut config = json!({ + "version": policy.version, + "containerId": container_id, + "lifecycle": { "destroyOnExit": true, "preservePolicy": !clear_policy }, + "process": { "commandLine": "", "timeout": policy.timeout_ms.unwrap_or(0) }, + "filesystem": { + "readwritePaths": fs.readwrite_paths, + "readonlyPaths": fs.readonly_paths, + "deniedPaths": fs.denied_paths, + }, + "ui": { + "disable": !policy.ui.as_ref().map(|u| u.allow_windows).unwrap_or(false), + "clipboard": policy.ui.as_ref().map(|u| u.clipboard).unwrap_or_default().wire(), + "injection": policy.ui.as_ref().map(|u| u.allow_input_injection).unwrap_or(false), + }, + }); + + // Mirror the SDK's `resolvesToHostFilteringBackend` (sdk/src/sandbox.ts): + // Linux (Bubblewrap/LXC) and macOS (Seatbelt) are treated as host-filtering + // backends, so `allowedHosts`/`blockedHosts` are accepted without + // `allowOutbound`; only Windows ProcessContainer requires `allowOutbound`. + // NB: Seatbelt can't actually enforce hostnames (`profile_builder` degrades a + // non-empty `allowedHosts` to allow-all outbound), but we accept it on macOS + // anyway to stay consistent with the SDK rather than diverging — keeping the + // two ports reconciled matters more than being stricter here. + let targets_host_filtering_backend = cfg!(any(target_os = "linux", target_os = "macos")); + + if let Some(net) = &policy.network { + if net.proxy.is_some() && cfg!(target_os = "macos") { + return Err(MxcError::malformed_request( + "Proxy configuration is not supported on macOS", + )); + } + + if !targets_host_filtering_backend + && (!net.allowed_hosts.is_empty() || !net.blocked_hosts.is_empty()) + && !net.allow_outbound + { + return Err(MxcError::malformed_request( + "allowedHosts/blockedHosts require allowOutbound to be true", + )); + } + + let mut network = json!({ + "defaultPolicy": if net.allow_outbound { "allow" } else { "block" }, + "allowLocalNetwork": net.allow_local_network, + "allowedHosts": net.allowed_hosts, + "blockedHosts": net.blocked_hosts, + }); + if let Some(proxy) = &net.proxy { + network["proxy"] = proxy_to_wire(proxy); + } + config["network"] = network; + } else { + config["network"] = json!({ "defaultPolicy": "block" }); + } + + apply_backend(&mut config, policy, &container_id); + Ok(config) +} + +fn proxy_to_wire(proxy: &ProxySpec) -> serde_json::Value { + use serde_json::json; + match proxy { + ProxySpec::BuiltinTestServer => json!({ "builtinTestServer": true }), + ProxySpec::Localhost(port) => json!({ "localhost": port }), + ProxySpec::Url(url) => json!({ "url": url }), + } +} + +/// Apply backend-specific fields, resolving the abstract `Process` intent the +/// same way the SDK does (Bubblewrap on Linux, Seatbelt on macOS, +/// BaseContainer on Windows). +fn apply_backend(config: &mut serde_json::Value, policy: &SandboxPolicy, container_id: &str) { + use serde_json::json; + + // Resolve the abstract Process intent per host. + config["containment"] = json!("process"); + + #[cfg(target_os = "linux")] + { + let _ = (policy, container_id); + apply_linux_network_policy(config); + } + + #[cfg(target_os = "macos")] + { + let _ = (policy, container_id); + config["containment"] = json!("seatbelt"); + if config.get("seatbelt").is_none() { + config["seatbelt"] = json!({}); + } + } + + #[cfg(target_os = "windows")] + { + let mut capabilities: Vec<&str> = Vec::new(); + if let Some(net) = &policy.network { + if net.allow_outbound { + capabilities.push("internetClient"); + } + if net.allow_local_network { + capabilities.push("privateNetworkClientServer"); + } + } + config["processContainer"] = json!({ + "name": container_id, + "leastPrivilege": false, + "capabilities": capabilities, + "ui": { + "isolation": "container", + "desktopSystemControl": false, + "systemSettings": "none", + "ime": false, + }, + }); + if let Some(network) = config.get_mut("network") { + let mode = if has_host_rules(network) { + "both" + } else { + "capabilities" + }; + network["enforcementMode"] = json!(mode); + } + } + + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + { + let _ = (policy, container_id); + } +} + +/// True when the network section carries any host allow/deny rules, deciding +/// whether host-level enforcement is engaged. (Linux + Windows only.) +#[cfg(any(target_os = "linux", target_os = "windows"))] +fn has_host_rules(network: &serde_json::Value) -> bool { + let non_empty = |key: &str| { + network + .get(key) + .and_then(|v| v.as_array()) + .is_some_and(|a| !a.is_empty()) + }; + non_empty("allowedHosts") || non_empty("blockedHosts") +} + +/// Promote network enforcement to `firewall` when host rules are present and +/// no cooperative proxy is configured — the Linux counterpart of the SDK's +/// `applyLinuxNetworkPolicy`. +#[cfg(target_os = "linux")] +fn apply_linux_network_policy(config: &mut serde_json::Value) { + use serde_json::json; + let Some(network) = config.get_mut("network") else { + return; + }; + let has_proxy = network.get("proxy").is_some(); + if has_host_rules(network) && !has_proxy { + network["enforcementMode"] = json!("firewall"); + } +} + +#[cfg(test)] +mod tests { + use super::ProxySpec; + + #[test] + fn proxy_builtin_test_server_true_is_accepted() { + let spec: ProxySpec = + serde_json::from_str(r#"{ "builtinTestServer": true }"#).expect("true is valid"); + assert!(matches!(spec, ProxySpec::BuiltinTestServer)); + } + + #[test] + fn proxy_builtin_test_server_false_is_rejected() { + // An explicit `false` must not silently select the (experimental, + // deliberately-permissive) built-in proxy — it is rejected as malformed. + let err = serde_json::from_str::(r#"{ "builtinTestServer": false }"#) + .expect_err("false must be rejected"); + assert!( + err.to_string().contains("builtinTestServer must be true"), + "unexpected error: {err}" + ); + } + + #[test] + fn proxy_conflicting_modes_are_rejected() { + // Several modes at once must be rejected (cr-005), not silently reduced + // to the first matching one. + let err = serde_json::from_str::( + r#"{ "builtinTestServer": true, "localhost": 8080 }"#, + ) + .expect_err("conflicting proxy modes must be rejected"); + assert!( + err.to_string().contains("exactly one"), + "unexpected error: {err}" + ); + } + + #[test] + fn proxy_localhost_and_url_still_parse() { + assert!(matches!( + serde_json::from_str::(r#"{ "localhost": 8080 }"#).expect("localhost"), + ProxySpec::Localhost(8080) + )); + assert!(matches!( + serde_json::from_str::(r#"{ "url": "http://proxy" }"#).expect("url"), + ProxySpec::Url(_) + )); + } + + #[cfg(target_os = "windows")] + #[test] + fn powershell_policy_grants_system_drive_root() { + use super::powershell_policy; + use std::fs; + use std::path::PathBuf; + + // Simulate a `$PSHOME` by creating a temp dir containing a fake pwsh.exe. + let unique = format!( + "mxc_pwsh_policy_test_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + let ps_home: PathBuf = std::env::temp_dir().join(unique); + fs::create_dir_all(&ps_home).expect("create temp $PSHOME"); + fs::write(ps_home.join("pwsh.exe"), b"").expect("create fake pwsh.exe"); + let ps_home_str = ps_home.to_string_lossy().into_owned(); + + let env = vec![("USERPROFILE".to_string(), "C:\\Users\\example".to_string())]; + let result = powershell_policy(std::slice::from_ref(&ps_home_str), &env); + + // Clean up before asserting so a failing assertion still leaves nothing. + let _ = fs::remove_dir_all(&ps_home); + + // The system-drive root (e.g. `C:\`) is granted read-only — pwsh + // enumerates the drive root on startup (mirrors `getPowerShellPolicy`). + // A bare drive root normalizes to a 2-char `X:` after trimming separators. + assert!( + result.readonly_paths.iter().any(|p| { + let trimmed = p.trim_end_matches(['\\', '/']); + trimmed.len() == 2 && trimmed.ends_with(':') + }), + "expected system-drive root in readonly paths: {:?}", + result.readonly_paths + ); + // PSReadLine command history stays read-write. + assert!( + result + .readwrite_paths + .iter() + .any(|p| p.contains("PSReadLine")), + "expected PSReadLine history in readwrite paths: {:?}", + result.readwrite_paths + ); + } + + use super::{build_request, NetworkSection, SandboxPolicy}; + + fn policy_with_network(network: NetworkSection) -> SandboxPolicy { + SandboxPolicy { + version: "0.7.0-alpha".to_string(), + filesystem: None, + network: Some(network), + ui: None, + timeout_ms: None, + } + } + + // macOS Seatbelt is treated as a host-filtering backend to mirror the SDK + // (`resolvesToHostFilteringBackend` in sdk/src/sandbox.ts), so `allowedHosts` + // is accepted with or without `allowOutbound` — consistency with the SDK over + // rejecting on macOS, even though Seatbelt can't actually filter by host. + #[cfg(target_os = "macos")] + #[test] + fn macos_allowed_hosts_without_outbound_is_accepted() { + // The SDK accepts allowedHosts without allowOutbound on Seatbelt, so the + // Rust port must too (the guard only applies to Windows ProcessContainer). + let policy = policy_with_network(NetworkSection { + allow_outbound: false, + allowed_hosts: vec!["example.com".to_string()], + ..Default::default() + }); + assert!( + build_request(&policy, None).is_ok(), + "macOS must accept allowedHosts without allowOutbound, matching the SDK" + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_allowed_hosts_with_outbound_is_accepted() { + // allowOutbound=true is the caller explicitly allowing outbound, so it + // builds (allowedHosts simply isn't enforceable on Seatbelt). + let policy = policy_with_network(NetworkSection { + allow_outbound: true, + allowed_hosts: vec!["example.com".to_string()], + ..Default::default() + }); + assert!( + build_request(&policy, None).is_ok(), + "outbound-allowed host filter should build" + ); + } + + #[test] + fn build_request_maps_filesystem_and_timeout() { + let policy = SandboxPolicy { + version: "0.7.0-alpha".to_string(), + filesystem: Some(super::FilesystemSection { + readwrite_paths: vec!["/tmp".to_string()], + readonly_paths: vec![], + denied_paths: vec![], + clear_policy_on_exit: None, + }), + network: None, + ui: None, + timeout_ms: Some(5000), + }; + + // Inspect the internal model the SDK maps to — a unit concern; the public + // API only hands back the opaque `SandboxRequest`. + let request = + build_request(&policy, Some("test-container")).expect("build_request should succeed"); + assert_eq!(request.inner.script_timeout, 5000); + assert!(request + .inner + .policy + .readwrite_paths + .contains(&"/tmp".to_string())); + assert!(request.inner.script_code.is_empty()); + } + + #[test] + fn set_env_formats_pairs_as_key_value_in_order() { + // The structured `(key, value)` setter mirrors the SDK env channel + // (`injectEnvIntoConfig`): each pair becomes a `KEY=VALUE` wire entry, in + // iteration order so a later duplicate key wins downstream. + let policy = SandboxPolicy { + version: "0.7.0-alpha".to_string(), + filesystem: None, + network: None, + ui: None, + timeout_ms: None, + }; + let mut request = build_request(&policy, None).expect("build_request should succeed"); + request.set_env([("FIRST", "1"), ("SECOND", "2")]); + assert_eq!(request.inner.env, vec!["FIRST=1", "SECOND=2"]); + } + + #[test] + fn build_request_preserves_clipboard_policy() { + use super::ClipboardPolicy as P; + use wxc_common::models::ClipboardPolicy as Wire; + + for (input, expected) in [ + (P::None, Wire::None), + (P::Read, Wire::Read), + (P::Write, Wire::Write), + (P::All, Wire::All), + ] { + let policy = SandboxPolicy { + version: "0.7.0-alpha".to_string(), + filesystem: None, + network: None, + ui: Some(super::UiSection { + allow_windows: true, + clipboard: input, + allow_input_injection: false, + }), + timeout_ms: None, + }; + let request = build_request(&policy, None).expect("build_request should succeed"); + assert_eq!( + request.inner.policy.ui.clipboard, expected, + "clipboard {input:?} should map to {expected:?}" + ); + } + } + + #[test] + fn build_request_maps_network_hosts() { + let policy = policy_with_network(NetworkSection { + allow_outbound: true, + allow_local_network: true, + allowed_hosts: vec!["allowed.example".to_string()], + blocked_hosts: vec!["blocked.example".to_string()], + ..Default::default() + }); + let request = build_request(&policy, None) + .expect("build_request should accept host rules with allowOutbound"); + assert!(request + .inner + .policy + .allowed_hosts + .contains(&"allowed.example".to_string())); + assert!(request + .inner + .policy + .blocked_hosts + .contains(&"blocked.example".to_string())); + assert!(request.inner.policy.allow_local_network); + } + + #[cfg(target_os = "macos")] + #[test] + fn seatbelt_extra_mach_lookups_and_keychain_round_trip() { + let policy = SandboxPolicy { + version: "0.7.0-alpha".to_string(), + filesystem: None, + network: None, + 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. + let mut request = build_request(&policy, None).expect("build_request"); + let mut union: Vec = request.seatbelt_extra_mach_lookups().to_vec(); + union.push("com.example.service".to_string()); + request.set_seatbelt_extra_mach_lookups(union.clone()); + request.set_seatbelt_keychain_access(true); + + assert_eq!(request.seatbelt_extra_mach_lookups(), union.as_slice()); + let cfg = request + .inner + .seatbelt + .as_ref() + .expect("seatbelt config on macOS"); + assert!(cfg.keychain_access); + assert!(cfg + .extra_mach_lookups + .contains(&"com.example.service".to_string())); + } +} diff --git a/src/core/mxc-sdk/src/sandbox.rs b/src/core/mxc-sdk/src/sandbox.rs new file mode 100644 index 000000000..35d78b32c --- /dev/null +++ b/src/core/mxc-sdk/src/sandbox.rs @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The SDK's sandbox handle — a crate-owned facade over the internal +//! `wxc_common` streaming handle, so the public API never exposes the +//! foundation crate's traits. + +use std::io::{Read, Write}; + +use wxc_common::sandbox_process::{SandboxProcess, StreamCloser as InnerCloser}; + +/// The outcome of waiting on a [`Sandbox`] (see [`Sandbox::wait`]). +/// +/// An ordinary exit and a timeout are both represented here as success +/// outcomes; [`Sandbox::wait`] reserves its `Err` for an actual OS / wait +/// failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WaitOutcome { + /// The process exited with this code. On Unix a process terminated by a + /// signal (rather than exiting normally) surfaces as `Exited(-1)`. + Exited(i32), + /// The request's `scriptTimeout` elapsed before the process exited; the + /// process and its whole tree were killed. + TimedOut, +} + +/// The captured result of running a [`Sandbox`] to completion via +/// [`wait_with_output`](Sandbox::wait_with_output). +#[derive(Debug, Clone)] +pub struct Output { + /// How the process finished. + pub outcome: WaitOutcome, + /// Everything the child wrote to stdout. + pub stdout: Vec, + /// Everything the child wrote to stderr. + pub stderr: Vec, +} + +/// A live sandboxed process, returned by [`spawn_sandbox`](crate::spawn_sandbox). +/// +/// Stream the child's stdio with the `take_*` accessors, wait for it, or kill +/// it (and its whole tree). No pty is allocated — the streams are ordinary +/// pipes. Any stdout/stderr the caller does not `take_*` is drained and +/// discarded by [`wait`](Self::wait). +pub struct Sandbox { + inner: Box, +} + +impl Sandbox { + pub(crate) fn new(inner: Box) -> Self { + Self { inner } + } + + /// Take the child's stdin pipe. Returns `None` after the first call. + pub fn take_stdin(&mut self) -> Option> { + self.inner.take_stdin() + } + + /// Take the child's stdout pipe. Returns `None` after the first call. + pub fn take_stdout(&mut self) -> Option> { + self.inner.take_stdout() + } + + /// Take the child's stderr pipe. Returns `None` after the first call. + pub fn take_stderr(&mut self) -> Option> { + self.inner.take_stderr() + } + + /// A [`StreamCloser`] that unblocks a parked blocking read on stdout without + /// killing the child. `None` if stdout was not piped. + pub fn stdout_closer(&self) -> Option { + self.inner.stdout_closer().map(StreamCloser::new) + } + + /// As [`stdout_closer`](Self::stdout_closer), for stderr. + pub fn stderr_closer(&self) -> Option { + self.inner.stderr_closer().map(StreamCloser::new) + } + + /// Non-blocking exit check: `Some(code)` if the child has exited. + pub fn try_wait(&mut self) -> std::io::Result> { + self.inner.try_wait() + } + + /// The child's process id. + pub fn id(&self) -> u32 { + self.inner.id() + } + + /// Kill the child and its process tree. + pub fn kill(&mut self) -> std::io::Result<()> { + self.inner.kill() + } + + /// Wait for the child to exit, draining and discarding any untaken + /// stdout/stderr so it can't block on a full pipe. + /// + /// Returns [`WaitOutcome::Exited`] with the exit code, or + /// [`WaitOutcome::TimedOut`] if the request's `scriptTimeout` elapsed (the + /// process and its tree are killed first). `Err` is reserved for an actual + /// OS / wait failure. + pub fn wait(&mut self) -> std::io::Result { + match self.inner.wait() { + Ok(code) => Ok(WaitOutcome::Exited(code)), + Err(e) if e.kind() == std::io::ErrorKind::TimedOut => Ok(WaitOutcome::TimedOut), + Err(e) => Err(e), + } + } + + /// Wait for the child to exit, capturing its stdout and stderr. + /// + /// The safe alternative to [`take_stdout`](Self::take_stdout) + + /// [`take_stderr`](Self::take_stderr): it drains both streams **concurrently** + /// on separate threads, so an output-heavy child can't deadlock (reading one + /// stream to EOF before the other can). Consumes the handle. + /// + /// `Err` is reserved for an actual OS / wait failure; a timeout is reported + /// as [`Output`] with `outcome: WaitOutcome::TimedOut` and whatever each + /// stream produced before the tree was killed. + pub fn wait_with_output(mut self) -> std::io::Result { + fn capture(stream: Option>) -> std::thread::JoinHandle> { + std::thread::spawn(move || { + let mut buf = Vec::new(); + if let Some(mut stream) = stream { + let _ = stream.read_to_end(&mut buf); + } + buf + }) + } + + // Take both streams before waiting so `wait` won't discard them, and + // read each on its own thread so the child never blocks on a full pipe. + let stdout = capture(self.inner.take_stdout()); + let stderr = capture(self.inner.take_stderr()); + let outcome = self.wait()?; + Ok(Output { + outcome, + stdout: stdout.join().unwrap_or_default(), + stderr: stderr.join().unwrap_or_default(), + }) + } +} + +/// Closes one of a [`Sandbox`]'s streams, unblocking a read parked on it without +/// killing the process. Obtained from [`Sandbox::stdout_closer`] / +/// [`Sandbox::stderr_closer`]. +pub struct StreamCloser { + inner: Box, +} + +impl StreamCloser { + fn new(inner: Box) -> Self { + Self { inner } + } + + /// Close the stream, making any read currently parked on it return. + pub fn close(&self) { + self.inner.close(); + } +} diff --git a/src/core/mxc-sdk/tests/sandbox.rs b/src/core/mxc-sdk/tests/sandbox.rs new file mode 100644 index 000000000..01c0e97a3 --- /dev/null +++ b/src/core/mxc-sdk/tests/sandbox.rs @@ -0,0 +1,308 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! End-to-end tests for the `mxc-sdk` library against the host backend. +//! +//! Seatbelt-specific cases run only on macOS. The library exposes only the +//! streaming API, so "run to completion" here means build a request via +//! [`build_request`], `spawn_sandbox`, read the (untaken) +//! stdout/stderr, then [`wait`](mxc_sdk::Sandbox::wait) for the exit code — +//! the same path the consumer drives. + +use mxc_sdk::{build_request, ErrorCode, SandboxPolicy}; +#[cfg(any(target_os = "macos", target_os = "windows"))] +use mxc_sdk::{spawn_sandbox, SandboxRequest, WaitOutcome}; + +/// A Seatbelt request exposing `/tmp` read-write, with the given command and +/// timeout (ms; `0` == run until exit). +#[cfg(target_os = "macos")] +fn seatbelt_request(command: &str, timeout_ms: u32) -> SandboxRequest { + let policy = SandboxPolicy { + version: "0.7.0-alpha".to_string(), + filesystem: Some(mxc_sdk::policy::FilesystemSection { + readwrite_paths: vec!["/tmp".to_string()], + readonly_paths: vec![], + denied_paths: vec![], + clear_policy_on_exit: None, + }), + network: None, + ui: None, + timeout_ms: if timeout_ms == 0 { + None + } else { + Some(timeout_ms) + }, + }; + let mut request = build_request(&policy, None).expect("build_request should succeed"); + request.set_script(command); + request +} + +/// A Windows ProcessContainer request exposing `C:\Windows\Temp` read-write. +/// The policy `version` selects the tier (>= 0.5 implies BaseContainer). +#[cfg(target_os = "windows")] +fn process_container_request(version: &str, command: &str, timeout_ms: u32) -> SandboxRequest { + let policy = SandboxPolicy { + version: version.to_string(), + filesystem: Some(mxc_sdk::policy::FilesystemSection { + readwrite_paths: vec!["C:\\Windows\\Temp".to_string()], + readonly_paths: vec![], + denied_paths: vec![], + clear_policy_on_exit: None, + }), + network: None, + ui: None, + timeout_ms: if timeout_ms == 0 { + None + } else { + Some(timeout_ms) + }, + }; + let mut request = build_request(&policy, None).expect("build_request should succeed"); + request.set_script(command); + request +} + +/// Outcome of running a sandbox to completion via the streaming API. +#[cfg(any(target_os = "macos", target_os = "windows"))] +#[derive(Debug)] +struct RunOutcome { + exit_code: i32, + timed_out: bool, + standard_out: String, + standard_err: String, +} + +/// Spawn a request, read its stdout/stderr concurrently, and wait for exit — +/// the streaming-API equivalent of running to completion. +#[cfg(any(target_os = "macos", target_os = "windows"))] +fn spawn_and_wait(request: SandboxRequest) -> Result { + use std::io::Read; + + fn read_thread( + reader: Option>, + ) -> Option> { + reader.map(|mut r| { + std::thread::spawn(move || { + let mut s = String::new(); + let _ = r.read_to_string(&mut s); + s + }) + }) + } + + let mut proc = spawn_sandbox(request)?; + let out_thread = read_thread(proc.take_stdout()); + let err_thread = read_thread(proc.take_stderr()); + let (exit_code, timed_out) = match proc.wait() { + Ok(WaitOutcome::Exited(code)) => (code, false), + Ok(WaitOutcome::TimedOut) => (-1, true), + Err(e) => panic!("wait failed: {e}"), + }; + let standard_out = out_thread + .map(|t| t.join().unwrap_or_default()) + .unwrap_or_default(); + let standard_err = err_thread + .map(|t| t.join().unwrap_or_default()) + .unwrap_or_default(); + Ok(RunOutcome { + exit_code, + timed_out, + standard_out, + standard_err, + }) +} + +#[test] +fn version_older_than_supported_is_rejected() { + // Schema version below the supported floor (>=0.4) must be rejected by the + // parser before any backend selection happens. + let policy = SandboxPolicy { + version: "0.3.0-alpha".to_string(), + filesystem: None, + network: None, + ui: None, + timeout_ms: None, + }; + + let err = + build_request(&policy, None).expect_err("an out-of-range schema version must be rejected"); + assert_eq!(err.code, ErrorCode::MalformedRequest); +} + +#[cfg(target_os = "macos")] +#[test] +fn seatbelt_does_not_leak_host_environment() { + // A host env var must not be visible to the sandboxed child (the request's + // env is the only source; the host environment is cleared). + std::env::set_var("MXC_HOST_SECRET", "leaked-value"); + + let result = spawn_and_wait(seatbelt_request("echo [$MXC_HOST_SECRET]", 10000)) + .expect("seatbelt run should succeed"); + std::env::remove_var("MXC_HOST_SECRET"); + + assert_eq!(result.exit_code, 0, "stderr: {}", result.standard_err); + assert!( + !result.standard_out.contains("leaked-value"), + "host env must not leak into the sandbox, got: {:?}", + result.standard_out + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn seatbelt_env_reaches_sandboxed_process() { + // An env entry set on the request must reach the sandboxed child. + let mut request = seatbelt_request("echo $MXC_TEST_VAR", 10000); + request.set_env([("MXC_TEST_VAR", "injected-value")]); + + let result = spawn_and_wait(request).expect("seatbelt run should succeed"); + + assert_eq!(result.exit_code, 0, "stderr: {}", result.standard_err); + assert!( + result.standard_out.contains("injected-value"), + "env var should reach the sandboxed process, got: {:?}", + result.standard_out + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn seatbelt_finite_timeout_fires() { + // A finite scriptTimeout shorter than the command's runtime must fire and + // terminate the process. + let start = std::time::Instant::now(); + let result = spawn_and_wait(seatbelt_request("sleep 30", 1000)) + .expect("seatbelt run should return a response"); + assert!(result.timed_out, "a timed-out run must report a timeout"); + assert!( + start.elapsed() < std::time::Duration::from_secs(20), + "timeout must fire well before the command's own 30s runtime" + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn seatbelt_captures_stderr_only() { + // Output written solely to stderr must be captured on standard_err, with + // standard_out left empty. + let result = spawn_and_wait(seatbelt_request("echo only-stderr 1>&2", 10000)) + .expect("seatbelt run should succeed"); + assert_eq!(result.exit_code, 0, "stderr: {}", result.standard_err); + assert!( + result.standard_err.contains("only-stderr"), + "stderr should be captured, got: {:?}", + result.standard_err + ); + assert!( + !result.standard_out.contains("only-stderr"), + "stdout should be empty, got: {:?}", + result.standard_out + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn seatbelt_reports_nonzero_exit_code() { + let result = + spawn_and_wait(seatbelt_request("exit 7", 10000)).expect("seatbelt run should succeed"); + + assert_eq!(result.exit_code, 7); + assert!( + !result.timed_out, + "a clean non-zero exit must not be reported as a timeout" + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn seatbelt_defaults_cwd_to_allowed_path_without_getcwd_leak() { + // No `cwd` set: the child must run in a sandbox-allowed directory (the + // first readwrite path) rather than inheriting a possibly-inaccessible + // host cwd, so getcwd() does not leak a permission error to stderr. + let result = + spawn_and_wait(seatbelt_request("/bin/pwd", 10000)).expect("seatbelt run should succeed"); + + assert_eq!(result.exit_code, 0, "stderr: {}", result.standard_err); + assert!( + result.standard_out.contains("tmp"), + "child cwd should default to the readwrite path, got: {:?}", + result.standard_out + ); + assert!( + !result.standard_err.contains("getcwd") + && !result.standard_err.contains("Operation not permitted"), + "no getcwd leak expected, stderr: {:?}", + result.standard_err + ); +} + +// --------------------------------------------------------------------------- +// Windows ProcessContainer (AppContainer + BaseContainer) — integration tests. +// +// These exercise the capture and timeout paths that regressed as review items +// #1 (BaseContainer ran with an already-closed process handle) and #2 +// (AppContainer timeout killed only the direct child, so it never fired). +// They run a real sandbox, so they require an elevated, host-prepped Windows +// host (see docs/host-prep.md) and are therefore `#[ignore]`d — run them with +// `cargo test -p mxc-sdk -- --ignored` on such a host. +// --------------------------------------------------------------------------- + +#[cfg(target_os = "windows")] +#[test] +#[ignore = "requires an elevated, host-prepped Windows host (see docs/host-prep.md)"] +fn base_container_captures_stdout() { + // Schema >= 0.5 implies the BaseContainer fallback. Regression guard for + // #1: a valid exit code and captured stdout prove the process handle was + // not closed out from under the wait. + let result = spawn_and_wait(process_container_request( + "0.7.0-alpha", + "cmd /c echo hello-base-container", + 30000, + )) + .expect("BaseContainer run should succeed"); + assert_eq!(result.exit_code, 0, "stderr: {}", result.standard_err); + assert!( + result.standard_out.contains("hello-base-container"), + "stdout should be captured, got: {:?}", + result.standard_out + ); +} + +#[cfg(target_os = "windows")] +#[test] +#[ignore = "requires an elevated, host-prepped Windows host (see docs/host-prep.md)"] +fn appcontainer_captures_stdout() { + // Schema 0.4 keeps us on the AppContainer fast path (no BaseContainer). + let result = spawn_and_wait(process_container_request( + "0.4.0-alpha", + "cmd /c echo hello-appcontainer", + 30000, + )) + .expect("AppContainer run should succeed"); + assert_eq!(result.exit_code, 0, "stderr: {}", result.standard_err); + assert!( + result.standard_out.contains("hello-appcontainer"), + "stdout should be captured, got: {:?}", + result.standard_out + ); +} + +#[cfg(target_os = "windows")] +#[test] +#[ignore = "requires an elevated, host-prepped Windows host (see docs/host-prep.md)"] +fn appcontainer_finite_timeout_fires() { + // Regression guard for #2: a finite timeout must fire even when the command + // spawns a descendant that keeps the inherited stdout write-end open. If + // the timeout only killed the direct child, the capture reader would block + // forever and this test would hang past the bounded wall-clock below. + let result = spawn_and_wait(process_container_request( + "0.4.0-alpha", + "cmd /c start /b ping -n 60 127.0.0.1 >nul & ping -n 60 127.0.0.1 >nul", + 2000, + )) + .expect("AppContainer run should return a response"); + assert!(result.timed_out, "a timed-out run must report a timeout"); + // The bounded wait is enforced by the test harness; a hang here is the + // failure mode the regression guards against. +} diff --git a/src/core/mxc-sdk/tests/sdk_helpers.rs b/src/core/mxc-sdk/tests/sdk_helpers.rs new file mode 100644 index 000000000..69e115393 --- /dev/null +++ b/src/core/mxc-sdk/tests/sdk_helpers.rs @@ -0,0 +1,245 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for the ported SDK helpers: policy discovery, platform support, and +//! the SandboxPolicy -> SandboxRequest builder. + +use mxc_sdk::{ + available_tools_policy, build_request, platform_support, temporary_files_policy, + user_profile_policy, SandboxPolicy, +}; + +#[cfg(target_os = "macos")] +use mxc_sdk::{spawn_sandbox, WaitOutcome}; + +fn env_pairs(pairs: &[(&str, &str)]) -> Vec<(String, String)> { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() +} + +#[test] +fn platform_support_reports_host() { + let support = platform_support(); + // Every platform this test runs on (macOS/Linux/Windows in CI) is supported. + assert!(support.is_supported, "reason: {:?}", support.reason); + assert!(!support.available_methods.is_empty()); +} + +#[cfg(target_os = "macos")] +#[test] +fn platform_support_macos_is_seatbelt() { + let support = platform_support(); + assert_eq!(support.available_methods, vec!["seatbelt".to_string()]); +} + +#[test] +fn available_tools_policy_filters_nonexistent_and_dedups() { + // A real dir (cwd), a bogus dir, and the real dir again under a known var. + let cwd = std::env::current_dir() + .unwrap() + .to_string_lossy() + .into_owned(); + let sep = if cfg!(target_os = "windows") { + ";" + } else { + ":" + }; + let path_val = format!("{cwd}{sep}/this/does/not/exist/xyzzy"); + let env = env_pairs(&[("PATH", &path_val), ("CARGO_HOME", &cwd)]); + + let result = available_tools_policy(Some(&env)); + + assert!( + result.readonly_paths.iter().any(|p| p.contains(&cwd)), + "the full resolved cwd should be discovered: cwd={cwd:?} paths={:?}", + result.readonly_paths + ); + assert!( + !result.readonly_paths.iter().any(|p| p.contains("xyzzy")), + "non-existent dir should be filtered: {:?}", + result.readonly_paths + ); + // cwd appeared twice (PATH + CARGO_HOME) but must be deduplicated. + let cwd_hits = result + .readonly_paths + .iter() + .filter(|p| { + p.ends_with( + std::path::Path::new(&cwd) + .file_name() + .unwrap() + .to_str() + .unwrap(), + ) + }) + .count(); + assert!( + cwd_hits <= 1, + "cwd should not be duplicated: {:?}", + result.readonly_paths + ); +} + +#[test] +fn temporary_files_policy_returns_existing_temp() { + let cwd = std::env::current_dir() + .unwrap() + .to_string_lossy() + .into_owned(); + let var = if cfg!(target_os = "windows") { + "TEMP" + } else { + "TMPDIR" + }; + let env = env_pairs(&[(var, &cwd)]); + + let result = temporary_files_policy(Some(&env)); + assert_eq!(result.readwrite_paths.len(), 1); + assert!(result.readonly_paths.is_empty()); +} + +#[test] +fn temporary_files_policy_empty_when_missing() { + let env = env_pairs(&[ + ("TEMP", "/no/such/temp/xyzzy"), + ("TMPDIR", "/no/such/temp/xyzzy"), + ]); + let result = temporary_files_policy(Some(&env)); + assert!(result.readwrite_paths.is_empty()); +} + +#[test] +fn user_profile_policy_does_not_panic() { + // Behaviour is host-dependent; assert it returns without error and never + // populates readwrite (it is a read-only fragment). + let result = user_profile_policy(); + assert!(result.readwrite_paths.is_empty()); +} + +#[test] +fn build_request_rejects_empty_version() { + // Parity with the SDK, which throws "Policy version is required". + let policy = SandboxPolicy { + version: String::new(), + filesystem: None, + network: None, + ui: None, + timeout_ms: None, + }; + + let err = build_request(&policy, None).expect_err("an empty policy version must be rejected"); + assert_eq!(err.code, mxc_sdk::ErrorCode::MalformedRequest); +} + +#[test] +fn build_request_host_rules_require_outbound() { + let policy = SandboxPolicy { + version: "0.7.0-alpha".to_string(), + filesystem: None, + network: Some(mxc_sdk::policy::NetworkSection { + allow_outbound: false, + allow_local_network: false, + allowed_hosts: vec!["example.com".to_string()], + blocked_hosts: vec![], + proxy: None, + }), + ui: None, + timeout_ms: None, + }; + + // Mirror the SDK's `resolvesToHostFilteringBackend`: Linux (Bubblewrap/LXC) + // and macOS (Seatbelt) accept host rules without `allowOutbound`; only the + // Windows ProcessContainer backend requires it. Either way it must not panic. + let result = build_request(&policy, None); + if cfg!(any(target_os = "linux", target_os = "macos")) { + assert!( + result.is_ok(), + "Linux/macOS host-filtering backends accept host rules without allowOutbound (matching the SDK)" + ); + } else { + assert!( + result.is_err(), + "Windows ProcessContainer requires allowOutbound for host rules" + ); + } +} + +#[cfg(target_os = "macos")] +#[test] +fn build_request_then_run_seatbelt() { + let policy = SandboxPolicy { + version: "0.7.0-alpha".to_string(), + filesystem: Some(mxc_sdk::policy::FilesystemSection { + readwrite_paths: vec!["/tmp".to_string()], + readonly_paths: vec![], + denied_paths: vec![], + clear_policy_on_exit: None, + }), + network: None, + ui: None, + timeout_ms: Some(10000), + }; + + let mut request = build_request(&policy, None).expect("build_request should succeed"); + request.set_script("echo built-from-policy"); + + let mut proc = spawn_sandbox(request).expect("spawn should succeed"); + let mut out = String::new(); + if let Some(mut stdout) = proc.take_stdout() { + let _ = std::io::Read::read_to_string(&mut stdout, &mut out); + } + let outcome = proc.wait().expect("wait should succeed"); + assert_eq!(outcome, WaitOutcome::Exited(0)); + assert!(out.contains("built-from-policy"), "got: {out:?}"); +} + +#[cfg(target_os = "linux")] +#[test] +fn platform_support_linux_methods_are_bubblewrap_only() { + let support = platform_support(); + // The crate dispatches only Bubblewrap on Linux (LXC has no captured / + // streaming path), so that is the only method it should ever report. + for method in &support.available_methods { + assert_eq!(method, "bubblewrap", "unexpected Linux method: {method}"); + } +} + +#[cfg(target_os = "windows")] +#[test] +fn platform_support_windows_is_processcontainer() { + let support = platform_support(); + assert!(support.is_supported, "reason: {:?}", support.reason); + assert_eq!( + support.available_methods, + vec!["processcontainer".to_string()] + ); +} + +#[test] +fn available_tools_policy_filters_system_critical() { + // A system-critical, existing directory on PATH must be filtered out so it + // never lands in readonly_paths. + let critical = if cfg!(target_os = "windows") { + format!( + "{}\\System32", + std::env::var("WINDIR").unwrap_or_else(|_| "C:\\Windows".to_string()) + ) + } else { + "/usr/bin".to_string() + }; + if !std::path::Path::new(&critical).is_dir() { + return; // skip if the critical dir doesn't exist on this host + } + let env = env_pairs(&[("PATH", &critical)]); + let result = available_tools_policy(Some(&env)); + assert!( + !result + .readonly_paths + .iter() + .any(|p| p.to_lowercase().contains("system32") || p == "/usr/bin"), + "system-critical dir must be filtered: {:?}", + result.readonly_paths + ); +} diff --git a/src/core/mxc-sdk/tests/streaming.rs b/src/core/mxc-sdk/tests/streaming.rs new file mode 100644 index 000000000..60668fd1a --- /dev/null +++ b/src/core/mxc-sdk/tests/streaming.rs @@ -0,0 +1,440 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Streaming (handle-based) API tests: live stdio, kill, and wait. +//! Seatbelt-specific cases run only on macOS. +//! +//! These drive the real consumer path: build a [`SandboxRequest`] from a +//! [`SandboxPolicy`] via `build_request`, fill in the command, then +//! `spawn_sandbox`. + +#![cfg(target_os = "macos")] + +use mxc_sdk::{build_request, spawn_sandbox, SandboxPolicy, SandboxRequest, WaitOutcome}; + +/// A Seatbelt streaming request (`/tmp` read-write) with the given command and +/// timeout (ms; `0` == run until exit, required for interactive/long cases). +#[cfg(target_os = "macos")] +fn seatbelt_request(command: &str, timeout_ms: u32) -> SandboxRequest { + let policy = SandboxPolicy { + version: "0.7.0-alpha".to_string(), + filesystem: Some(mxc_sdk::policy::FilesystemSection { + readwrite_paths: vec!["/tmp".to_string()], + readonly_paths: vec![], + denied_paths: vec![], + clear_policy_on_exit: None, + }), + network: None, + ui: None, + timeout_ms: if timeout_ms == 0 { + None + } else { + Some(timeout_ms) + }, + }; + let mut request = build_request(&policy, None).expect("build_request should succeed"); + request.set_script(command); + request +} + +#[cfg(target_os = "macos")] +#[test] +fn streaming_double_take_returns_none() { + let mut proc = spawn_sandbox(seatbelt_request("cat", 0)).expect("spawn"); + + assert!( + proc.take_stdin().is_some(), + "first take_stdin yields the pipe" + ); + assert!(proc.take_stdin().is_none(), "second take_stdin yields None"); + assert!( + proc.take_stdout().is_some(), + "first take_stdout yields the pipe" + ); + assert!( + proc.take_stdout().is_none(), + "second take_stdout yields None" + ); + assert!( + proc.take_stderr().is_some(), + "first take_stderr yields the pipe" + ); + assert!( + proc.take_stderr().is_none(), + "second take_stderr yields None" + ); + + proc.kill().expect("kill"); + let _ = proc.wait(); +} + +#[cfg(target_os = "macos")] +#[test] +fn streaming_try_wait_reports_exit_after_completion() { + let mut proc = spawn_sandbox(seatbelt_request("true", 0)).expect("spawn"); + + // Poll try_wait until the quick command exits; it must then report Some. + let mut code = None; + for _ in 0..100 { + if let Some(c) = proc.try_wait().expect("try_wait") { + code = Some(c); + break; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + let code = code.expect("process should exit and try_wait report it"); + assert_eq!(code, 0, "quick command should exit 0"); +} + +#[test] +fn streaming_kill_after_reap_is_a_noop() { + // Regression: once the child has exited and been reaped (here via `wait`), + // `kill()` must not signal its pid/pgid again — a recycled pid could belong + // to an unrelated process (group). The post-reap `kill()` is a clean no-op. + let mut proc = spawn_sandbox(seatbelt_request("true", 0)).expect("spawn"); + assert_eq!( + proc.wait().expect("wait"), + WaitOutcome::Exited(0), + "quick command should exit 0" + ); + proc.kill().expect("kill after reap is a no-op Ok"); + proc.kill().expect("repeat kill after reap stays Ok"); +} + +#[test] +fn streaming_kill_after_try_wait_reap_is_a_noop() { + // The exact race the review flagged: `try_wait()` reaps the exited child, so + // a later `kill()` must not signal the now-recycled pid/pgid. Poll try_wait + // to completion, then `kill()` must stay a clean no-op. + let mut proc = spawn_sandbox(seatbelt_request("true", 0)).expect("spawn"); + let mut reaped = false; + for _ in 0..100 { + if proc.try_wait().expect("try_wait").is_some() { + reaped = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + assert!( + reaped, + "quick command should exit and try_wait should reap it" + ); + proc.kill().expect("kill after try_wait reap is a no-op Ok"); + proc.kill().expect("repeat kill stays Ok"); +} + +#[test] +fn streaming_double_kill_before_wait_completes_promptly() { + // Calling `kill()` twice before `wait()` must be stable (both Ok), and + // `wait()` must then complete promptly rather than hang. + let mut proc = spawn_sandbox(seatbelt_request("sleep 30", 0)).expect("spawn"); + proc.kill().expect("first kill"); + proc.kill().expect("second kill stays Ok"); + let start = std::time::Instant::now(); + let _ = proc.wait(); + assert!( + start.elapsed() < std::time::Duration::from_secs(5), + "wait() after a double kill should complete promptly, took {:?}", + start.elapsed() + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn streaming_stdout_closer_unblocks_parked_read_without_killing() { + use std::io::Read; + + // `sleep` produces no output yet holds its stdout pipe write-end open, so a + // read parks indefinitely (mirroring a backgrounded descendant that keeps a + // pipe open past the foreground command's exit). The stdout closer must EOF + // that read promptly *without* terminating the still-running child — a plain + // `kill()` would defeat the point. + let mut proc = spawn_sandbox(seatbelt_request("sleep 30", 0)).expect("spawn"); + + let mut stdout = proc.take_stdout().expect("stdout available"); + // The closer is valid even though stdout has already been taken. + let closer = proc.stdout_closer().expect("stdout closer available"); + assert!( + proc.stderr_closer().is_some(), + "stderr closer should also be available in pipes mode" + ); + + // Park a blocking read on a worker thread; with the writer held open it + // cannot return on its own. + let reader = std::thread::spawn(move || { + let mut buf = [0u8; 64]; + let start = std::time::Instant::now(); + let n = stdout.read(&mut buf).expect("read returns"); + (n, start.elapsed()) + }); + + // Let the read park, confirm the child is still running, then close. + std::thread::sleep(std::time::Duration::from_millis(200)); + assert!( + proc.try_wait().expect("try_wait").is_none(), + "child should still be running while the read is parked" + ); + closer.close(); + + let (n, elapsed) = reader.join().expect("reader thread"); + assert_eq!(n, 0, "closed stream reports EOF"); + assert!( + elapsed < std::time::Duration::from_secs(10), + "read should return promptly after close (elapsed: {elapsed:?})" + ); + + // The closer must not have terminated the child. + assert!( + proc.try_wait().expect("try_wait").is_none(), + "stdout_closer must not terminate the child" + ); + + // A second close is a harmless no-op. + closer.close(); + + proc.kill().expect("kill"); + let _ = proc.wait(); +} + +#[cfg(target_os = "macos")] +#[test] +fn streaming_wait_discards_untaken_streams() { + let mut proc = + spawn_sandbox(seatbelt_request("echo streamed-out", 0)).expect("spawn should succeed"); + // Take nothing -> wait() drains and discards the output, returning only + // the exit code. + assert_eq!( + proc.wait().expect("wait should succeed"), + WaitOutcome::Exited(0) + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn streaming_wait_with_output_captures_both_streams() { + // wait_with_output drains stdout and stderr concurrently, so a child that + // writes to both is captured without the take-both deadlock foot-gun. + let proc = spawn_sandbox(seatbelt_request("echo to-out; echo to-err 1>&2", 0)) + .expect("spawn should succeed"); + let output = proc + .wait_with_output() + .expect("wait_with_output should succeed"); + assert_eq!(output.outcome, WaitOutcome::Exited(0)); + assert!( + String::from_utf8_lossy(&output.stdout).contains("to-out"), + "stdout: {:?}", + String::from_utf8_lossy(&output.stdout) + ); + assert!( + String::from_utf8_lossy(&output.stderr).contains("to-err"), + "stderr: {:?}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn streaming_bidirectional_stdio() { + use std::io::{Read, Write}; + + // `cat` echoes stdin to stdout until EOF, then exits. + let mut proc = spawn_sandbox(seatbelt_request("cat", 0)).expect("spawn"); + + let mut stdin = proc.take_stdin().expect("stdin available"); + let mut stdout = proc.take_stdout().expect("stdout available"); + + stdin.write_all(b"ping-pong\n").expect("write stdin"); + drop(stdin); // close -> cat sees EOF and exits + + let mut out = String::new(); + stdout.read_to_string(&mut out).expect("read stdout"); + assert!(out.contains("ping-pong"), "got: {:?}", out); + + assert_eq!(proc.wait().expect("wait"), WaitOutcome::Exited(0)); +} + +#[cfg(target_os = "macos")] +#[test] +fn streaming_kill_terminates_process() { + let mut proc = spawn_sandbox(seatbelt_request("sleep 30", 0)).expect("spawn"); + + // Still running shortly after spawn. + assert!(proc.try_wait().expect("try_wait").is_none()); + + proc.kill().expect("kill should succeed"); + + // After kill, the process must be reapable and not report success. + assert_ne!( + proc.wait().expect("wait after kill"), + WaitOutcome::Exited(0), + "killed process should not exit 0" + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn streaming_kill_terminates_forked_descendant_quickly() { + // Regression for the early-kill race: when the shell *forks* the inner + // command (`echo` then `sleep`), an early `kill()` could SIGTERM the shell + // (which dies) before the just-forked `sleep` joined the group — leaving + // `sleep` alive and the follow-up `wait()` blocking for its full runtime. + // The whole tree must die promptly regardless. + let mut proc = spawn_sandbox(seatbelt_request("echo hi; sleep 30", 0)).expect("spawn"); + + proc.kill().expect("kill should succeed"); + + let start = std::time::Instant::now(); + let _ = proc.wait(); + assert!( + start.elapsed() < std::time::Duration::from_secs(10), + "wait() must return promptly after kill(), not wait out the child's \ + 30s runtime (elapsed: {:?})", + start.elapsed() + ); +} + +#[cfg(target_os = "macos")] +fn pid_alive(pid: u32) -> bool { + // Signal 0 probes existence without delivering a signal — no PID-reuse + // race from spawning `ps`, and no false "dead" if the probe itself fails. + let rc = unsafe { libc::kill(pid as libc::pid_t, 0) }; + if rc == 0 { + return true; + } + // ESRCH => no such process (dead). Any other errno (e.g. EPERM: the pid + // exists but we may not signal it) means it is still alive. + std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH) +} + +#[cfg(target_os = "macos")] +#[test] +fn streaming_kill_terminates_process_tree() { + use std::io::{BufRead, BufReader}; + + // The sandboxed shell backgrounds a `sleep` (a descendant), prints its + // pid, then blocks. `kill()` must take the whole process group down, + // including that descendant. + let mut proc = + spawn_sandbox(seatbelt_request("sleep 300 & echo CHILD=$!; sleep 300", 0)).expect("spawn"); + + assert!(proc.id() > 0, "id() should expose the child pid"); + + let stdout = proc.take_stdout().expect("stdout"); + let mut reader = BufReader::new(stdout); + let mut line = String::new(); + reader.read_line(&mut line).expect("read descendant pid"); + let descendant: u32 = line + .trim() + .strip_prefix("CHILD=") + .expect("CHILD= prefix") + .parse() + .expect("descendant pid"); + + assert!( + pid_alive(descendant), + "descendant {descendant} should be running before kill" + ); + + proc.kill().expect("kill"); + let _ = proc.wait(); + + let mut gone = false; + for _ in 0..60 { + if !pid_alive(descendant) { + gone = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + assert!( + gone, + "descendant {descendant} should be killed with the process tree" + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn streaming_timeout_kills_process_tree() { + use std::io::{BufRead, BufReader}; + + // 1s timeout; the shell backgrounds a long sleep (descendant), prints its + // pid, then blocks past the timeout. wait()'s timeout branch must group- + // kill, taking the descendant down too. + let mut proc = spawn_sandbox(seatbelt_request( + "sleep 300 & echo CHILD=$!; sleep 300", + 1000, + )) + .expect("spawn"); + + let stdout = proc.take_stdout().expect("stdout"); + let mut reader = BufReader::new(stdout); + let mut line = String::new(); + reader.read_line(&mut line).expect("read descendant pid"); + let descendant: u32 = line + .trim() + .strip_prefix("CHILD=") + .expect("CHILD= prefix") + .parse() + .expect("descendant pid"); + + assert_eq!( + proc.wait().expect("wait yields an outcome"), + WaitOutcome::TimedOut, + "timed-out process should report a timeout" + ); + + let mut gone = false; + for _ in 0..60 { + if !pid_alive(descendant) { + gone = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + assert!(gone, "descendant {descendant} should be killed on timeout"); +} + +#[cfg(target_os = "macos")] +#[test] +fn streaming_wait_returns_when_descendant_holds_not_taken_stream_open() { + // The foreground command exits immediately, but a backgrounded `sleep` + // inherits and holds stdout's write end open. We take NOTHING, so `wait()` + // drains stdout/stderr itself; with no timeout (wait-forever) it must still + // return promptly once the foreground child exits — the held-open descendant + // pipe must not wedge the discard drain (cr-002 regression). + let mut proc = + spawn_sandbox(seatbelt_request("sleep 30 & exit 0", 0)).expect("spawn should succeed"); + + let start = std::time::Instant::now(); + assert_eq!( + proc.wait().expect("wait should return"), + WaitOutcome::Exited(0), + "foreground command exits 0" + ); + assert!( + start.elapsed() < std::time::Duration::from_secs(10), + "wait() must return promptly, not block on the descendant's 30s pipe hold \ + (elapsed: {:?})", + start.elapsed() + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn streaming_honors_sub_500ms_timeout() { + // A sub-500ms timeout used to be rejected outright; it must now be accepted + // and enforced (cr-011), and fire with low latency (cr-016). `sleep 30` + // exceeds it, so wait() reports a timeout promptly. + let mut proc = spawn_sandbox(seatbelt_request("sleep 30", 200)).expect("spawn"); + let start = std::time::Instant::now(); + assert_eq!( + proc.wait().expect("wait yields an outcome"), + WaitOutcome::TimedOut, + "sub-500ms timeout should fire" + ); + assert!( + start.elapsed() < std::time::Duration::from_secs(5), + "timeout should fire near 200ms, not wait out the 30s sleep (elapsed: {:?})", + start.elapsed() + ); +} diff --git a/src/core/mxc-sdk/tests/streaming_processcontainer.rs b/src/core/mxc-sdk/tests/streaming_processcontainer.rs new file mode 100644 index 000000000..d7941ce19 --- /dev/null +++ b/src/core/mxc-sdk/tests/streaming_processcontainer.rs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Windows ProcessContainer streaming integration test, in its own +//! Windows-gated file. The sibling `streaming.rs` is `#![cfg(macos)]`, which +//! would otherwise make a `#[cfg(windows)]` test there impossible to compile. +//! Requires an elevated, host-prepped Windows host (see docs/host-prep.md), so +//! it is `#[ignore]`d. + +#![cfg(target_os = "windows")] + +use mxc_sdk::{build_request, spawn_sandbox, SandboxPolicy, WaitOutcome}; + +#[test] +#[ignore = "requires an elevated, host-prepped Windows host (see docs/host-prep.md)"] +fn streaming_processcontainer_bidirectional_stdio() { + use std::io::{Read, Write}; + + let policy = SandboxPolicy { + version: "0.7.0-alpha".to_string(), + filesystem: Some(mxc_sdk::policy::FilesystemSection { + readwrite_paths: vec!["C:\\Windows\\Temp".to_string()], + readonly_paths: vec![], + denied_paths: vec![], + clear_policy_on_exit: None, + }), + network: None, + ui: None, + timeout_ms: None, + }; + let mut request = build_request(&policy, None).expect("build_request"); + // `cmd /c more` echoes stdin to stdout until EOF, then exits. + request.set_script("cmd /c more"); + let mut proc = spawn_sandbox(request).expect("spawn"); + + let mut stdin = proc.take_stdin().expect("stdin available"); + let mut stdout = proc.take_stdout().expect("stdout available"); + + stdin.write_all(b"ping-pong\r\n").expect("write stdin"); + drop(stdin); + + let mut out = String::new(); + stdout.read_to_string(&mut out).expect("read stdout"); + assert!(out.contains("ping-pong"), "got: {:?}", out); + + assert_eq!(proc.wait().expect("wait"), WaitOutcome::Exited(0)); +} diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index 546662588..61278bd77 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -427,8 +427,25 @@ pub fn load_request_with_options( convert_raw_config_inner(raw, logger, true, opts.allow_missing_command) } -/// Loads a request and routes to the one-shot or state-aware path based on -/// presence of the wire-format `phase` field. Errors are categorised so the +/// Build a request from an already-parsed wire-format config [`Value`], running +/// the same validation and wire→model mapping as [`load_request_with_options`] +/// but without a base64 (or file) round-trip. For in-process callers (e.g. the +/// `mxc` crate) that already hold the config as JSON and would otherwise pay to +/// serialise → base64 → decode → re-parse it. +/// +/// [`Value`]: serde_json::Value +pub fn load_request_from_value( + config: serde_json::Value, + logger: &mut Logger, + allow_missing_command: bool, +) -> Result { + let raw: RawConfig = serde_json::from_value(config).map_err(|e| { + logger.log_line("Error parsing JSON"); + WxcError::ConfigParse(format!("JSON parse error: {}", e)) + })?; + + convert_raw_config_inner(raw, logger, true, allow_missing_command) +} /// driver can pick the right output convention per path (envelope on stdout /// for state-aware, diagnostic on stderr for one-shot and pre-discrimination /// failures).