Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/` |
Expand All @@ -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<dyn SandboxProcess>` + a `diagnose_exit` hook for enriching launch-failure exits) and the generic `Runner<B>` 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")]`
Expand Down
4 changes: 2 additions & 2 deletions docs/sandbox-policy/v1/policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions src/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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" }
Expand Down
27 changes: 27 additions & 0 deletions src/core/mxc-sdk/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 }
152 changes: 152 additions & 0 deletions src/core/mxc-sdk/README.md
Original file line number Diff line number Diff line change
@@ -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<dyn std::error::Error>>(())
```

[`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<dyn std::error::Error>>(())
```

The handle is modelled on [`std::process::Child`]:

- `take_stdin()` β†’ `Box<dyn Write + Send>`, `take_stdout()` / `take_stderr()`
β†’ `Box<dyn Read + Send>` (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<StreamCloser>`: 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.
Loading
Loading