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
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ The workspace is organized into six top-level directories under `src/`:
- `mxc_engine` is the **single execution engine** β€” the one home for "given an `ExecutionRequest`, run it". It owns: run-to-completion backend selection (`run` / `resolve_runner`, covering **all** backends, incl. the Windows ProcessContainer BaseContainer/AppContainer BFS/DACL fallback tiers via `appcontainer_common::dispatcher::dispatch_with_fallback`, and every experimental backend, feature-gated); streaming (`spawn` β†’ `Box<dyn SandboxProcess>`); state-aware lifecycle dispatch (`run_state_aware`, including Windows Sandbox and IsolationSession); host probing (`platform_support` / `PlatformSupport`); and config building (`build_request` / `build_request_with_containment`, `SandboxPolicy` + sections, `available_tools_policy`/`user_profile_policy`/`temporary_files_policy`). It depends on the backend crates (cfg-split: appcontainer/windows_sandbox lifecycle/isolation_session/wslc/nanvix on Windows, bubblewrap/lxc/nanvix on Linux, seatbelt on macOS) so it can't live in `wxc_common`. Both the executor binaries and `mxc-sdk` call into it. `ResolvedRunner` carries the boxed runner plus (Windows only) the optional `DaclManager` guard, so `wxc-exec` can park the guard for its signal handler.
- `mxc-sdk` is the **public Rust SDK** β€” a thin facade over `mxc_engine`. Build a `SandboxRequest` with `build_request`, then either `run(request)` (run-to-completion; returns an `Output` with the `WaitOutcome`, captured `stdout`/`stderr`, warnings, and optional structured output metadata) or `spawn_sandbox(request)` (returns a `Sandbox` handle for live bidirectional stdio β€” `take_stdin`/`take_stdout`/`take_stderr`, `kill()`, `wait()` returning a `WaitOutcome` (`Exited(i32)` / `TimedOut`) as `io::Result`, `output_metadata()` after terminal completion, or `wait_with_output()`). It re-exports the engine's config-building surface (`build_request`, `build_request_with_containment` + `Containment`/`WslcSection`, `mxc_sdk::policy::{SandboxPolicy sections}`, discovery helpers) and `platform_support`; `mod sandbox` (wrapping the engine's `SandboxProcess` in `Sandbox`) is its only local module. No pty is ever allocated. Streaming supports Seatbelt (macOS), Bubblewrap (Linux), Windows ProcessContainer (AppContainer + BaseContainer), and WSLC (Windows, experimental β€” needs the crate's `wslc` feature plus `SandboxRequest::set_experimental(true)`; no stdin and `id() == 0`, since the WSLC SDK exposes neither); other backends return `ErrorCode::UnsupportedContainment`.
- The lower-level execution surface lives in `wxc_common::sandbox_process`: the `SandboxBackend` trait (`validate` + `spawn(request, logger, StdioMode) -> Box<dyn SandboxProcess>` + a `diagnose_exit` hook) and the generic `Runner<B>` adapter that bridges any `SandboxBackend` to the run-to-completion `ScriptRunner` (via `spawn(StdioMode::Inherit)` then `wait()`). `SandboxProcess::output_metadata()` carries backend-produced structured outputs after terminal teardown without writing to process-global stdio. `StdioMode::Pipes` hands the caller live stdin/stdout/stderr (what the `mxc-sdk` streaming path uses); `StdioMode::Inherit` lets the child inherit the host's stdio (what the executor binaries use, preserving the TTY under a pty). `SandboxBackend` is implemented for Seatbelt, Bubblewrap, Windows ProcessContainer, and WSLC (on `wslc_common::WSLContainerRunner` itself, which shares one container lifecycle β€” `start_container` β€” between its streaming `SandboxBackend` and run-to-completion `ScriptRunner` impls, differing only in where the WSLC SDK's output callbacks write).
- `mxc_ffi` (`ffi/mxc_ffi`, `crate-type = ["cdylib", "staticlib", "lib"]`) is a flat, panic-safe **C ABI over `mxc-sdk`** for language bindings. `mxc_run(policyJson, command, out)` runs a sandbox to completion, filling a `#[repr(C)] MxcRunResult` (status + exit_code + timed_out + owned stdout/stderr/error/output-metadata C strings); every entry point is `catch_unwind`-wrapped so a panic becomes a status code, never an unwind. Its `build.rs` runs **csbindgen** to generate the C# P/Invoke (`sdk/dotnet/Microsoft.Mxc.Sdk/Native/NativeMethods.g.cs`), gated behind the crate's **`dotnetsdk`** feature (off by default, so the whole-workspace backend build matrix doesn't compile csbindgen). The generated file is **not committed** (gitignored); the C# csproj regenerates it at build time and `scripts/check-dotnet-bindings-codegen.js` runs the codegen in CI and asserts the expected entry points are produced. The C ABI is **not a stable external contract** (native + binding are co-versioned and generated together; see the crate docs). It exposes three surfaces: **run-to-completion** (`mxc_run`), **streaming** (`mxc_spawn` β†’ opaque `MxcSandbox` handle; `mxc_stream_read`/`write`/`flush`, `mxc_sandbox_take_stdin`/`stdout`/`stderr`, `mxc_sandbox_id`/`try_wait`/`wait`/`kill`/`output_metadata_json`/`free`, in `src/streaming.rs`), and the **state-aware lifecycle** (`mxc_state_aware` for the envelope phases + `mxc_state_aware_exec` returning a live streaming handle, in `src/state_aware.rs`). All three `.rs` files are csbindgen inputs in `build.rs`; the `MXC_STATUS_*` space already reserves the state-aware phase codes.
- `mxc_ffi` (`ffi/mxc_ffi`, `crate-type = ["cdylib", "staticlib", "lib"]`) is a flat, panic-safe **C ABI over `mxc-sdk`** for language bindings. `mxc_run(policyJson, command, out)` runs a sandbox to completion, filling a `#[repr(C)] MxcRunResult` (status + exit_code + timed_out, owned stdout/stderr/output-metadata C strings, and an `MxcErrorDetail` carrying the failure message plus the failing API call and its platform status); every entry point is `catch_unwind`-wrapped so a panic becomes a status code, never an unwind. Its `build.rs` runs **csbindgen** to generate the C# P/Invoke (`sdk/dotnet/Microsoft.Mxc.Sdk/Native/NativeMethods.g.cs`), gated behind the crate's **`dotnetsdk`** feature (off by default, so the whole-workspace backend build matrix doesn't compile csbindgen). The generated file is **not committed** (gitignored); the C# csproj regenerates it at build time and `scripts/check-dotnet-bindings-codegen.js` runs the codegen in CI and asserts the expected entry points are produced. The C ABI is **not a stable external contract** (native + binding are co-versioned and generated together; see the crate docs). It exposes three surfaces: **run-to-completion** (`mxc_run`), **streaming** (`mxc_spawn` β†’ opaque `MxcSandbox` handle; `mxc_stream_read`/`write`/`flush`, `mxc_sandbox_take_stdin`/`stdout`/`stderr`, `mxc_sandbox_id`/`try_wait`/`wait`/`kill`/`output_metadata_json`/`free`, in `src/streaming.rs`), and the **state-aware lifecycle** (`mxc_state_aware` for the envelope phases + `mxc_state_aware_exec` returning a live streaming handle, in `src/state_aware.rs`). All four `.rs` files are csbindgen inputs in `build.rs` (the shared `MxcErrorDetail` lives in `src/error_detail.rs`); the `MXC_STATUS_*` space already reserves the state-aware phase codes.
- `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)`.)
- `learning_mode_core` is the **cross-platform learning-mode / captureDenials model + output emitter**: `DeniedResource` (+ `ResourceType`/`AccessType`), `DenialSummary`, the `DenialAnalyzer` decode trait, and `emit` β€” which writes the on-disk denials deliverable as a **single JSON document** `{ "denials": [...], "summary": {...} }` (`write_document` / `DenialsDocument`) and defines the serializable `DenialsOutputPointer`. It carries no OS-specific code (must not depend on any `backends/*` crate); the Windows ETL decoder implementing `DenialAnalyzer` lives in `backends/learning_mode/windows`. When `processContainer.captureDenials` is set, native PSEC/V2 seals and decodes a managed ETL locally, while guarded WPR relogs its host-wide source into a process-scoped ETL before analysis; both routes write the same canonical JSON through shared output plumbing and return neutral `wxc_common` metadata. Explicit `retainEtl` preserves the native sealed trace or the guarded process-scoped relogged trace after a terminal wait; abandonment discards it. `wxc-exec` serializes the metadata as the one-line stderr pointer at the CLI boundary; Rust/C#/FFI callers receive it programmatically. Each denial's `resource` field holds the file path or the AppContainer capability name; capability denials resolve their capability SID to a friendly name via `backends/learning_mode/windows`'s `capability_names` (well-known `S-1-15-3-…` SID β†’ policy name; custom hashed SIDs fall back to the SID string).
- `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`
Expand Down
3 changes: 1 addition & 2 deletions docs/isolation-session/state-aware-rust.md
Original file line number Diff line number Diff line change
Expand Up @@ -402,8 +402,7 @@ that is the API's own message, passed through verbatim.
| MXC-internal failure (relay threads, console handles) | β€” | β€” | β€” |
| `Policy` and the MXC-side `malformed_*` rejections | β€” | β€” | β€” |

**Invariant:** `nativeCode` implies `operation`, and `remediation` implies `operation`.
`operation` marks that an API operation was in flight; neither refinement appears alone.
**Invariant:** `operation` marks that an API operation was in flight.

`operation` is the interface-qualified member name β€” for example
`IsoSessionOps.StopSessionAsync`. It is deliberately low-cardinality and free of call
Expand Down
9 changes: 4 additions & 5 deletions docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -816,11 +816,10 @@ logic on `code` first.

**Stability.** Unlike `code`, which is a closed and versioned enum, the *values* of `operation` and `nativeCode` are **best-effort diagnostics and may change without a schema version bump**. They are derived from the underlying platform API β€” for IsolationSession, from the projected WinRT class and method names β€” which MXC does not own and cannot version. Consumers should aggregate on them for telemetry and log them for diagnosis, but branch program logic on `code`, and should not treat a particular `operation` value as a guarantee. (MXC's own end-to-end tests do pin exact values; that is deliberate β€” they verify MXC's mapping, and move with it in the same change.)

**Invariant:** `nativeCode` implies `operation`, and `remediation` implies `operation`.
`operation` marks that an API operation was in flight; the other two refine it, and
neither ever appears alone. A failure MXC raises before or outside any API call β€” a
malformed request or id, a policy rejection, or an internal failure of MXC's own
machinery β€” carries only `code` and `message`.
**Invariant:** `operation` marks that an API operation was in flight. A failure
MXC raises before or outside any API call β€” a malformed request or id, a policy
rejection, or an internal failure of MXC's own machinery β€” carries only `code`
and `message`.

**Which fields earn a place here.** A named top-level field is for a **backend-neutral**
concept: `operation`, `nativeCode` and `remediation` all apply equally to a Windows
Expand Down
126 changes: 122 additions & 4 deletions scripts/check-dotnet-bindings-codegen.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,18 @@ const generated = join(
"NativeMethods.g.cs"
);

// The extern entry points the C# SDK P/Invokes; the generated file must expose
// each one. Keep in sync with the `#[no_mangle] extern "C"` fns in
// src/ffi/mxc_ffi/src/lib.rs.
// A smoke-test subset of the extern entry points the C# SDK P/Invokes β€” not the
// whole set, which is larger. The compiler is the real backstop: csbindgen emits
// a binding only for a fn carrying `#[no_mangle]` or `#[export_name]`, and takes
// the `EntryPoint` from whichever of the two determines the export. So renaming
// a fn, removing it, or dropping that attribute makes the generated method
// change or disappear, and the hand-written call sites stop compiling.
//
// Keep in sync with the `#[no_mangle] extern "C"` fns in src/ffi/mxc_ffi/src/.
const REQUIRED_ENTRY_POINTS = [
"mxc_run",
"mxc_run_result_free",
"mxc_error_detail_free",
"mxc_string_free",
"mxc_version",
];
Expand Down Expand Up @@ -72,6 +78,118 @@ if (missing.length > 0) {
process.exit(1);
}

// The same set of Rust sources is named twice: once for csbindgen to read, and
// once as the MSBuild target's incremental `Inputs`. MSBuild skips the target
// when its output is newer than every declared input, so a file missing from
// the second list means an incremental C# build can compile against stale
// declarations β€” and cargo's own `rerun-if-changed` never gets consulted,
// because the target never runs. A clean CI build always regenerates and so
// cannot catch it.
//
// This is not hypothetical: adding `error_detail.rs` to build.rs without adding
// it to the csproj is exactly how the lists drifted once already.
const CRATE_REL_ROOT = "ffi/mxc_ffi";
const buildRs = readFileSync(
join(repoRoot, "src", "ffi", "mxc_ffi", "build.rs"),
"utf8"
);
const csproj = readFileSync(
join(
repoRoot,
"sdk",
"dotnet",
"Microsoft.Mxc.Sdk",
"Microsoft.Mxc.Sdk.csproj"
),
"utf8"
);

// Enumerate every call site, then parse each one β€” rather than matching only
// the shape we expect. A regex that skips what it cannot read would let an
// unrecognised-but-live input (a trailing comma, a `const` argument, a call
// split across lines) pass unnoticed while the four literal calls keep the
// zero-call guard quiet. Anything unparseable fails the gate instead.
const callSites = [...buildRs.matchAll(/\.input_extern_file\s*\(/g)];
if (callSites.length === 0) {
console.error(
"ERROR: found no `.input_extern_file(...)` calls in src/ffi/mxc_ffi/build.rs.\n" +
" The parity check cannot be trusted; has the codegen setup changed?"
);
process.exit(1);
}

const csbindgenInputs = [];
const unparseable = [];
for (const site of callSites) {
const rest = buildRs.slice(site.index + site[0].length);
// Only a plain string literal, optionally followed by a trailing comma and
// whitespace, is understood. Anything else is reported, not skipped.
const literal = rest.match(/^\s*"([^"]+)"\s*,?\s*\)/);
if (literal) {
csbindgenInputs.push(literal[1]);
} else {
unparseable.push(rest.split("\n")[0].trim().slice(0, 60));
}
}
if (unparseable.length > 0) {
console.error(
"ERROR: could not read the argument of some `.input_extern_file(...)` call(s)\n" +
"in src/ffi/mxc_ffi/build.rs, so this gate cannot prove the MSBuild inputs\n" +
"cover them:\n" +
unparseable.map((s) => ` - .input_extern_file(${s}`).join("\n") +
"\n\nUse a plain string literal, or teach this check the new form."
);
process.exit(1);
}

// Scope to the owning target's opening tag β€” attributes live there, and
// matching only `<Target …>` cannot bleed into child elements. The lookahead
// finds `Name` wherever it sits among the attributes: requiring it first would
// make legal, behaviour-preserving XML reordering look like a missing target.
// `\b` keeps `<TargetFramework>` from matching.
const target = csproj.match(
/<Target\b(?=[^>]*\bName="GenerateNativeBindings")[^>]*>/
);
if (!target) {
console.error(
"ERROR: could not find the `GenerateNativeBindings` target in the csproj."
);
process.exit(1);
}
const inputsAttr = target[0].match(/Inputs="([^"]*)"/);
if (!inputsAttr) {
console.error(
"ERROR: the `GenerateNativeBindings` target declares no `Inputs` attribute,\n" +
" so MSBuild cannot know when to regenerate the bindings."
);
process.exit(1);
}

// build.rs paths are crate-relative ("src/lib.rs"); the csproj spells them from
// the repo's src dir ("$(MxcSrcDir)/ffi/mxc_ffi/src/lib.rs"). Compare on the
// crate-rooted tail so the check tolerates how the csproj roots itself but
// still rejects the same filename under a different crate.
const declaredInputs = inputsAttr[1]
.split(";")
.map((p) => p.trim().replace(/\\/g, "/"))
.filter(Boolean);
const notDeclared = csbindgenInputs.filter((rel) => {
const expected = `${CRATE_REL_ROOT}/${rel}`;
return !declaredInputs.some((declared) => declared.endsWith(expected));
});
if (notDeclared.length > 0) {
console.error(
"ERROR: Rust source(s) read by csbindgen are missing from the C# project's\n" +
"MSBuild `Inputs`, so an incremental build can skip regeneration and use\n" +
"stale bindings:\n" +
notDeclared.map((p) => ` - ${CRATE_REL_ROOT}/${p}`).join("\n") +
"\n\nAdd each to the GenerateNativeBindings `Inputs` in\n" +
" sdk/dotnet/Microsoft.Mxc.Sdk/Microsoft.Mxc.Sdk.csproj"
);
process.exit(1);
}

console.log(
`C# bindings codegen OK: generated with ${REQUIRED_ENTRY_POINTS.length} expected entry points`
`C# bindings codegen OK: generated with ${REQUIRED_ENTRY_POINTS.length} expected entry points; ` +
`${csbindgenInputs.length} csbindgen source(s) all declared as MSBuild inputs`
Comment thread
adpa-ms marked this conversation as resolved.
);
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- The native detail struct is marshalled with raw pointers. -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>

<ItemGroup>
Expand Down
Loading
Loading