diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index 678ca7d24e..ce62b90c15 100644 --- a/.agents/skills/build-openshell-mxc-windows/SKILL.md +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -1,6 +1,6 @@ --- name: build-openshell-mxc-windows -description: Maintain and validate OpenShell's build-only Windows MSVC lane for x64 and ARM64. Use when working on Windows compilation, `windows:*` mise tasks, unsupported Windows compute-driver contracts, or Windows build reports. This skill does not implement Docker, Kubernetes, Podman, VM, MXC driver, policy translation, MSI, service, or supervisor runtime support on Windows. +description: Maintain and validate OpenShell's native Windows MSVC and MXC runtime lane for x64 and ARM64. Use when working on Windows compilation, `windows:*` mise tasks, the MXC supervisor/sandbox pairing, unsupported Windows compute-driver contracts, or Windows build reports. This skill does not implement Docker, Kubernetes, Podman, VM, MSI, or service support on Windows. metadata: internal: true --- @@ -12,11 +12,13 @@ OpenShell repository. The Windows lane is already present in `main`; do not treat this skill as a first-time porting recipe unless the user explicitly asks for a new fork or a from-scratch bring-up. -The lane is build-only. It validates that OpenShell can compile and test on -Windows MSVC for the supported deliverables: +The lane validates that OpenShell can compile and test on Windows MSVC for the +supported deliverables: - `openshell-gateway.exe` - `openshell.exe` +- `openshell-supervisor.exe` (host RFC 0012 isolation backend) +- `openshell-windows-sandbox.exe` (MXC ProcessContainer boundary) It intentionally does not make Windows a Docker, Kubernetes, Podman, or VM runtime host. @@ -29,6 +31,38 @@ Preserve readiness gating on authenticated gateway acceptance and reconnection. Shared Sandbox Protocol audit validation defaults to strict Linux evidence; concrete platform validators must be selected by the implementing backend. +The dedicated Windows sandbox binary links the Windows-only `openshell-mxc-boundary` +library for process operations, containment confirmation, and loopback +forwarding. The generic sandbox does not link this library and remains a Linux +runtime. Do not reintroduce a dedicated supervisor-relay executable or link +the compute driver into the sandbox. The supervisor remains a separate host +process. Include boundary-library regression tests in native workspace tests; +their real process/socket checks are not qualification of MXC enforcement. + +Keep MXC audit schemas and their validators in the Windows boundary library. +Register its `MxcRuntimeBackend` under `openshell-mxc` at supervisor composition; +it reuses the shared authenticated Sandbox Protocol, whose default Linux +backend remains separate. Shared protocol code +must not interpret MXC evidence. Do not reintroduce driver-owned proxy startup +or gateway create-time credential snapshots. Gateway JSONL audit output +is portable and an explicit operator opt-in, independent of the selected driver. +Keep platform directory conventions in shared path utilities and ETW capture +in the MXC driver. Gateway composition supplies generic connection inputs; the +driver resolves its endpoint defaults and TLS server name. Supervisor gateway +sessions and main attachment must work without SSH on any host. Gate only the +Unix socket adapter, not TCP readiness or authenticated session retries. + +Adopt the shared protocol's authenticated policy discovery and monotonic +provider-publication generations. Do not order opaque credential revisions +numerically. MXC rejects unsupported provider file delivery before launch or +environment replacement; it must not acknowledge files it did not install. + +Preserve main's explicit outer-fence evidence contract. The current MXC mapper +allows broad host loopback and has no verified live-revocation evidence, so +configuration flags cannot establish the required guarantees. Keep confirmation +fail-closed until native qualification proves each property; a supported PSEC +host alone does not close these implementation gaps. + ## Current Repository Shape The Windows build lane is implemented by these tracked files: @@ -52,8 +86,8 @@ In scope: - Refreshing a local checkout to the latest upstream GitHub `main`. - Maintaining `tasks/windows.toml` and `tasks/scripts/windows-msvc.ps1`. - Running x64 and ARM64 MSVC checks. -- Building x64 and ARM64 release binaries for `openshell-gateway` and - `openshell`. +- Building x64 and ARM64 release binaries for `openshell-gateway`, `openshell`, + `openshell-supervisor`, and `openshell-windows-sandbox`. - Running workspace tests on a native x64 or ARM64 host. - Running focused unsupported-driver contract tests. - Reporting test counts, skipped/gated areas, warnings, artifacts, and logs. @@ -66,12 +100,9 @@ Out of scope: - Kubernetes support on Windows. - Podman, Podman machine, or Podman Desktop support on Windows. - VM, Hyper-V, WSL, libkrun, or VM-backed sandbox execution on Windows. -- New MXC compute driver crate. -- OpenShell to MXC policy translation. - Windows named-pipe driver IPC. - Windows Credential Manager or DPAPI integration. - MSI, WinGet, Windows service registration, or installer work. -- Windows supervisor runtime port. ## Hard Rules @@ -164,6 +195,16 @@ mise run --skip-tools windows:test:x64 mise run --skip-tools windows:test:unsupported:x64 ``` +The two `windows:test:mxc-real:*` tasks are host-specific and mutually +exclusive on a single host (each rejects the other architecture -- see the +table below): run `windows:test:mxc-real:x64` on an x64 host, or +`windows:test:mxc-real:arm64` on an ARM64 host, as part of validating this +subsystem -- run the one matching your host architecture, not both, and not +neither. Both are skip-safe (they print a SKIP reason and exit 0 when +`wxc-exec` or the matching backend isn't available), so running the +arch-appropriate task is always safe even without real MXC hardware. Neither +is part of `windows:ci`'s ordered contract, so invoke it explicitly. + For full validation, detect the Windows host architecture first and choose the native lane dynamically: @@ -172,12 +213,14 @@ $arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture switch ($arch.ToString()) { "X64" { mise run --skip-tools windows:ci + mise run --skip-tools windows:test:mxc-real:x64 } "Arm64" { mise run --skip-tools windows:check:arm64 mise run --skip-tools windows:build:arm64 mise run --skip-tools windows:test:arm64 mise run --skip-tools windows:test:unsupported:arm64 + mise run --skip-tools windows:test:mxc-real:arm64 mise run --skip-tools windows:artifacts } default { @@ -249,12 +292,14 @@ crypto dependency builds. |---|---| | `windows:check:x64` | `cargo check --workspace` for `x86_64-pc-windows-msvc`, excluding unsupported Windows packages as top-level workspace targets. | | `windows:check:arm64` | `cargo check --workspace` for `aarch64-pc-windows-msvc`, with the same top-level exclusions. | -| `windows:build:x64` | Release-builds `openshell-gateway.exe` and `openshell.exe` for x64. | -| `windows:build:arm64` | Release-builds `openshell-gateway.exe` and `openshell.exe` for ARM64. | +| `windows:build:x64` | Release-builds `openshell-gateway.exe`, `openshell.exe`, `openshell-supervisor.exe`, and `openshell-windows-sandbox.exe` for x64. | +| `windows:build:arm64` | Release-builds the same four binaries for ARM64. | | `windows:test:x64` | Runs native x64 workspace tests with `--no-fail-fast`, excluding unsupported Windows packages as top-level workspace targets. | | `windows:test:arm64` | Runs native ARM64 workspace tests with `--no-fail-fast` and the same package exclusions. Rejects non-ARM64 hosts. | | `windows:test:unsupported:x64` | Re-runs focused `openshell-gateway` tests for unsupported Windows driver behavior. | | `windows:test:unsupported:arm64` | Re-runs the same focused contracts natively on ARM64. Rejects non-ARM64 hosts. | +| `windows:test:mxc-real:x64` | Runs the serial, ignored real-`wxc-exec` integration suite natively on x64 through the MSVC wrapper. Rejects non-x64 hosts. | +| `windows:test:mxc-real:arm64` | Runs the same real-`wxc-exec` suite natively on ARM64. Rejects non-ARM64 hosts. | | `windows:artifacts` | Reports size and SHA256 for release artifacts that exist. | | `windows:ci` | Runs the full ordered x64-host Windows CI lane, plus ARM64 check/build when not skipped. | @@ -299,6 +344,22 @@ validation; GitHub Actions does not re-run it after the full suite. ## Test Accounting Guidance +For MXC validation, also run `windows:e2e:mxc` +after the native release build. The harness uses .NET port discovery, disposable +TOML and CLI registration, and owner-only Ed25519 keys generated by OpenSSL on +PATH. Results live under `target/windows-e2e-results`; signing keys stay outside +bundles and are deleted unless `-KeepRunning` is explicitly requested. + +E2E has no mock mode and rejects `OPENSHELL_MXC_MOCK_WXC=1`. Unit-test mocks +are not E2E coverage. Every selected scenario must pass for a successful exit; +any skip reports `INCOMPLETE` and exits non-zero. Never bypass audit to make +tests pass. Real runtime scenarios require native ProcessContainer PSEC egress +filtering and ingress host-loopback support. `wxc-exec --probe` is authoritative, +not the Windows build number. All-skipped real runs are not passes. Separate +test-internal `SKIP` messages from Cargo's passed count in the real integration +suite, and report a verified unsupported-host rejection independently from +positive admission coverage. + When reporting `windows:ci`, distinguish these categories: - Passed tests from the full x64 workspace test log. @@ -324,6 +385,8 @@ Useful log files: | `test-aarch64-pc-windows-msvc.log` | Full native ARM64 workspace test output. | | `test-x86_64-pc-windows-msvc-unsupported-*.log` | Focused unsupported-driver contract output. | | `test-aarch64-pc-windows-msvc-unsupported-*.log` | Focused native ARM64 contract output. | +| `test-x86_64-pc-windows-msvc-mxc-real.log` | Native x64 real-MXC integration output. | +| `test-aarch64-pc-windows-msvc-mxc-real.log` | Native ARM64 real-MXC integration output. | The first check downloads the pinned official Z3 archive for the target architecture through `z3-sys`. GitHub Actions authenticates the lookup with its diff --git a/.agents/skills/build-openshell-mxc-windows/reference.md b/.agents/skills/build-openshell-mxc-windows/reference.md index 471eed009d..a34cb2eb31 100644 --- a/.agents/skills/build-openshell-mxc-windows/reference.md +++ b/.agents/skills/build-openshell-mxc-windows/reference.md @@ -1,7 +1,7 @@ # Reference: Windows MSVC maintenance lane Companion to [SKILL.md](SKILL.md). Use this file for quick lookup while -maintaining the existing build-only Windows MSVC lane. +maintaining the native Windows MSVC and MXC runtime lane. ## Lane Files @@ -75,7 +75,7 @@ Ninja to `PATH`, while the crypto crates select `clang-cl`. Use a short ## Unsupported Driver Rules -Windows is a build target only. These runtimes remain unsupported: +These Windows runtimes remain unsupported: - Docker - Kubernetes @@ -110,16 +110,14 @@ top-level workspace targets for check/test: --exclude openshell-driver-podman --exclude openshell-driver-vault --exclude openshell-driver-vm ---exclude openshell-sandbox --exclude openshell-vfio ``` The gateway keeps platform configuration and unsupported-operation contracts -without depending on the Docker, Kubernetes, Podman, sandbox runtime, -standalone supervisor, supervisor process runtime, VM, or VFIO crates. The MXC -driver does depend on the cross-platform supervisor network library for its host -egress proxy. The Kubernetes Secrets and Vault libraries still compile as -gateway dependencies; only their standalone Unix-socket binaries and +without depending on the Docker, Kubernetes, Podman, VM, or VFIO runtime crates. +The MXC runtime compiles the supervisor, supervisor-process library, and sandbox +boundary on Windows. The Kubernetes Secrets and Vault libraries still compile +as gateway dependencies; only their standalone Unix-socket binaries and package-level tests are excluded as top-level targets. The supervisor and supervisor-process packages now participate as top-level diff --git a/AGENTS.md b/AGENTS.md index b049c28f50..c62fecebb2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,7 @@ Do not rely on this file for a full inventory. The detailed public and contribut | `crates/openshell-conformance-cli/` | Conformance CLI | Legacy local `list` and `run` entrypoint pending follow-up cleanup | | `crates/openshell-server/` | Gateway server | Control-plane API, sandbox lifecycle, auth boundary | | `crates/openshell-sandbox/` | Sandbox runtime | Capability-free workload launcher, process identity, and seccomp-mediated I/O | +| `crates/openshell-mxc-boundary/` | Windows sandbox boundary | Windows boundary library and dedicated openshell-windows-sandbox binary for MXC process operations, containment confirmation, and shared-protocol forwarding | | `crates/openshell-supervisor/` | Supervisor runtime | Gateway session, policy evaluation, credentials, and upstream networking | | `crates/openshell-binary-identity/` | Binary identity | Shared trusted procfs executable identity resolution for isolation backends | | `crates/openshell-isolation-interface/` | Isolation backend interface | RFC 0012 `IsolationBackend` trait and types; the supervisor-facing runtime contract | @@ -50,7 +51,7 @@ Do not rely on this file for a full inventory. The detailed public and contribut | `crates/openshell-driver-docker/` | Docker compute driver | In-process `ComputeDriver` backend for local Docker sandbox containers | | `crates/openshell-driver-podman/` | Podman compute driver | In-process `ComputeDriver` backend for local Podman sandbox containers | | `crates/openshell-driver-vm/` | VM compute driver | Standalone libkrun-backed `ComputeDriver` subprocess (embeds its own rootfs + runtime) | -| `crates/openshell-driver-mxc/` | Microsoft MXC compute driver | In-process Windows AppContainer and isolation-session compute backend | +| `crates/openshell-driver-mxc/` | Microsoft MXC compute driver | In-process Windows ProcessContainer backend that pairs a host isolation-backend supervisor with `openshell-windows-sandbox` inside MXC | | `crates/openshell-prover/` | Policy prover | Policy verification and proof generation | | `crates/openshell-prover-cli/` | Policy prover CLI | Standalone local policy boundary checks | | `crates/openshell-server-macros/` | Server macros | Compile-time helpers for gateway RPC authorization | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 95c9d43ffb..37395f00e5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -105,7 +105,7 @@ Contributor and maintainer skills live in `.agents/skills/`. They are marked int | Triage | `triage-issue` | Assess, classify, and route community-filed issues | | Platform | `helm-dev-environment` | Start and manage the local Kubernetes development environment | | Platform | `tui-development` | Development guide for the ratatui-based terminal UI | -| Platform | `build-openshell-mxc-windows` | Maintain and validate the build-only x64 and ARM64 Windows MSVC lane | +| Platform | `build-openshell-mxc-windows` | Maintain and validate the x64 and ARM64 Windows MSVC and MXC runtime lane | | Documentation | `update-docs-from-commits` | Scan recent commits and draft doc updates for user-facing changes | | Maintenance | `sync-agent-infra` | Detect and fix drift across agent-first infrastructure files | | Reference | `sbom` | Generate SBOMs and resolve dependency licenses | diff --git a/Cargo.lock b/Cargo.lock index c6173fee09..cf0f7d8415 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4208,13 +4208,17 @@ dependencies = [ name = "openshell-driver-mxc" version = "0.0.0" dependencies = [ + "anyhow", "base64 0.22.1", "futures", "noyalib", "openshell-core", + "openshell-isolation-interface", + "openshell-mxc-boundary", "openshell-ocsf", "openshell-policy", - "openshell-supervisor-network", + "openshell-sandbox-backend", + "rand 0.9.4", "rustls", "serde", "serde_json", @@ -4416,6 +4420,30 @@ dependencies = [ "tokio", ] +[[package]] +name = "openshell-mxc-boundary" +version = "0.0.0" +dependencies = [ + "async-trait", + "clap", + "openshell-core", + "openshell-isolation-interface", + "openshell-ocsf", + "openshell-sandbox-backend", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "tokio", + "tokio-rustls", + "tokio-stream", + "tonic", + "tracing", + "tracing-subscriber", + "url", + "windows", +] + [[package]] name = "openshell-ocsf" version = "0.0.0" @@ -4567,6 +4595,7 @@ dependencies = [ "tonic", "tracing", "tracing-subscriber", + "url", "uuid", ] @@ -4741,6 +4770,7 @@ dependencies = [ "openshell-core", "openshell-extension-core", "openshell-isolation-interface", + "openshell-mxc-boundary", "openshell-ocsf", "openshell-policy", "openshell-sandbox-backend", diff --git a/Cargo.toml b/Cargo.toml index a50457f136..0cf0642288 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,7 +58,7 @@ miette = { version = "7", features = ["fancy"] } thiserror = "2" # Windows platform APIs (ETW/TDH audit consumer in openshell-driver-mxc; Windows-only) -windows = { version = "0.62", features = ["Wdk_System_Threading", "Win32_Foundation", "Win32_System_Diagnostics_Etw", "Win32_System_Time"] } +windows = { version = "0.62", features = ["Wdk_System_Threading", "Win32_Foundation", "Win32_Security", "Win32_System_Diagnostics_Etw", "Win32_System_Threading", "Win32_System_Time"] } anyhow = "1" # Logging/Tracing diff --git a/crates/openshell-cli/src/commands/common.rs b/crates/openshell-cli/src/commands/common.rs index 49fd66a9a8..316e590925 100644 --- a/crates/openshell-cli/src/commands/common.rs +++ b/crates/openshell-cli/src/commands/common.rs @@ -891,6 +891,47 @@ pub fn parse_env_pairs(items: &[String]) -> Result> { Ok(map) } +/// Resolve `--env-from KEY[=ENVVAR]` values from the CLI process environment. +/// +/// This keeps environment values out of process arguments while preserving the +/// same sandbox environment validation as `--env KEY=VALUE`. +pub fn parse_env_from_pairs(items: &[String]) -> Result> { + let mut map = HashMap::new(); + + for item in items { + let (key, env_name) = match item.split_once('=') { + Some((key, env_name)) => (key.trim(), env_name.trim()), + None => (item.trim(), item.trim()), + }; + if !is_valid_env_name(key) { + return Err(miette::miette!( + "--env-from key must match [A-Za-z_][A-Za-z0-9_]*; got '{key}'" + )); + } + if key.starts_with("OPENSHELL_") { + return Err(miette::miette!( + "--env-from keys starting with OPENSHELL_ are reserved; got '{key}'" + )); + } + if !is_valid_env_name(env_name) { + return Err(miette::miette!( + "--env-from source must match [A-Za-z_][A-Za-z0-9_]*; got '{env_name}'" + )); + } + if map.contains_key(key) { + return Err(miette::miette!("duplicate --env-from sandbox key '{key}'")); + } + let value = std::env::var(env_name).map_err(|_| { + miette::miette!( + "--env-from source environment variable '{env_name}' is not set or is not valid Unicode" + ) + })?; + map.insert(key.to_string(), value); + } + + Ok(map) +} + /// Resolve `--secret-material-env KEY[=ENVVAR]` values from the CLI process /// environment (`ENVVAR` defaults to `KEY`) so secrets never transit argv. pub fn parse_secret_material_env_pairs(items: &[String]) -> Result> { diff --git a/crates/openshell-cli/src/commands/gateway.rs b/crates/openshell-cli/src/commands/gateway.rs index 07bd31cb47..861c040f7e 100644 --- a/crates/openshell-cli/src/commands/gateway.rs +++ b/crates/openshell-cli/src/commands/gateway.rs @@ -46,6 +46,7 @@ struct ComputeDriverInfoView { struct ComputeDriverCapabilitiesView { driver_name: String, driver_version: String, + supports_ui_policy: bool, } #[derive(Debug, Clone)] @@ -417,6 +418,7 @@ pub async fn gateway_info( capabilities: ComputeDriverCapabilitiesView { driver_name: capabilities.driver_name, driver_version: capabilities.driver_version, + supports_ui_policy: capabilities.supports_ui_policy, }, } }) @@ -514,6 +516,15 @@ fn print_compute_driver_info(drivers: &[ComputeDriverInfoView]) { "Driver version:".dimmed(), driver.capabilities.driver_version ); + println!( + " {} {}", + "UI policy:".dimmed(), + if driver.capabilities.supports_ui_policy { + "supported" + } else { + "unsupported" + } + ); } } @@ -532,6 +543,7 @@ fn gateway_info_to_json(view: &GatewayInfoView) -> serde_json::Value { "capabilities": { "driver_name": &driver.capabilities.driver_name, "driver_version": &driver.capabilities.driver_version, + "supports_ui_policy": driver.capabilities.supports_ui_policy, }, })) .collect::>(), @@ -1899,6 +1911,7 @@ mod tests { capabilities: ComputeDriverCapabilitiesView { driver_name: "podman".to_string(), driver_version: "0.0.75".to_string(), + supports_ui_policy: false, }, }], extensions: vec![ExtensionInfoView { @@ -1927,6 +1940,10 @@ mod tests { json["compute_drivers"][0]["capabilities"]["driver_version"], "0.0.75" ); + assert_eq!( + json["compute_drivers"][0]["capabilities"]["supports_ui_policy"], + false + ); } #[test] diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index ef40f79936..817eaebf7d 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1442,7 +1442,7 @@ enum SandboxCommands { name: Option, /// Create the sandbox from a named sandbox template. - #[arg(long, conflicts_with_all = ["from", "gpu", "cpu", "memory", "driver_config_json", "envs"])] + #[arg(long, conflicts_with_all = ["from", "gpu", "cpu", "memory", "driver_config_json", "envs", "env_from"])] template: Option, /// Sandbox source: a rootfs tar archive (`.tar`, `.tar.gz`, or `.tgz`) @@ -1584,6 +1584,13 @@ enum SandboxCommands { #[arg(long = "env", value_name = "KEY=VALUE")] envs: Vec, + /// Set a sandbox environment variable from the CLI process environment. + /// + /// Format: `KEY[=ENVVAR]`. When `ENVVAR` is omitted, `KEY` is used. + /// The value does not appear in the CLI process arguments. Repeatable. + #[arg(long = "env-from", value_name = "KEY[=ENVVAR]")] + env_from: Vec, + /// Suppress warnings when --env values look like credentials. #[arg(long = "no-credential-warnings")] no_credential_warnings: bool, @@ -1665,8 +1672,16 @@ enum SandboxCommands { names: Vec, /// Delete all sandboxes. - #[arg(long, conflicts_with = "names")] + #[arg(long, conflicts_with_all = ["names", "expected_id", "expected_resource_version"])] all: bool, + + /// Delete only if the current sandbox has this exact immutable ID. + #[arg(long = "expected-id", value_name = "ID")] + expected_id: Option, + + /// Delete only if the current sandbox has this resource version. + #[arg(long = "expected-resource-version", value_name = "VERSION")] + expected_resource_version: Option, }, /// Stop a sandbox while preserving its workspace. @@ -3359,6 +3374,7 @@ async fn run_async() -> Result<()> { no_auto_providers, labels, envs, + env_from, no_credential_warnings, approval_mode, output, @@ -3396,7 +3412,12 @@ async fn run_async() -> Result<()> { } // Parse --env flags into a HashMap. - let env_map = run::parse_env_pairs(&envs)?; + let mut env_map = run::parse_env_pairs(&envs)?; + for (key, value) in run::parse_env_from_pairs(&env_from)? { + if env_map.insert(key.clone(), value).is_some() { + return Err(miette::miette!("duplicate environment key '{key}'")); + } + } // Parse --upload specs into [(local_path, sandbox_path, git_ignore)]. let upload_specs: Vec<(String, Option, bool)> = upload @@ -3560,11 +3581,18 @@ async fn run_async() -> Result<()> { ) .await?; } - SandboxCommands::Delete { names, all } => { + SandboxCommands::Delete { + names, + all, + expected_id, + expected_resource_version, + } => { run::sandbox_delete( endpoint, &names, all, + expected_id.as_deref(), + expected_resource_version, &cli.workspace, &tls, &ctx.name, @@ -5509,6 +5537,48 @@ mod tests { )); } + #[test] + fn sandbox_delete_accepts_identity_preconditions() { + let cli = Cli::try_parse_from([ + "openshell", + "sandbox", + "delete", + "demo", + "--expected-id", + "sb-123", + "--expected-resource-version", + "17", + ]) + .expect("identity-guarded sandbox delete should parse"); + + assert!(matches!( + cli.command, + Some(Commands::Sandbox { + command: Some(SandboxCommands::Delete { + ref names, + all: false, + expected_id: Some(ref expected_id), + expected_resource_version: Some(17), + }) + }) if names == &["demo"] && expected_id == "sb-123" + )); + } + + #[test] + fn sandbox_delete_all_rejects_identity_preconditions() { + assert!( + Cli::try_parse_from([ + "openshell", + "sandbox", + "delete", + "--all", + "--expected-id", + "sb-123", + ]) + .is_err() + ); + } + #[test] fn sandbox_list_accepts_output_json() { let cli = Cli::try_parse_from(["openshell", "sandbox", "list", "-o", "json"]) diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 3aa775685f..002df2e853 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -4,8 +4,8 @@ //! CLI command implementations. pub use crate::commands::common::{ - PolicyGetView, parse_credential_expiry_cli_value, parse_env_pairs, parse_key_value_pairs, - parse_secret_material_env_pairs, warn_credential_env_vars, + PolicyGetView, parse_credential_expiry_cli_value, parse_env_from_pairs, parse_env_pairs, + parse_key_value_pairs, parse_secret_material_env_pairs, warn_credential_env_vars, }; use crate::commands::common::{ ProvisioningDisplay, ProvisioningStep, confirm_global_setting_delete, @@ -410,7 +410,9 @@ async fn finalize_sandbox_create_session( } let names = [sandbox_name.to_string()]; - if let Err(err) = sandbox_delete(server, &names, false, workspace, tls, gateway).await { + if let Err(err) = + sandbox_delete(server, &names, false, None, None, workspace, tls, gateway).await + { if let Ok(exit_code) = session_result.as_ref() { return Err(miette::miette!( "sandbox command exited with status {exit_code}, but ephemeral cleanup failed: {err}" @@ -3715,14 +3717,35 @@ fn labels_display(labels: &HashMap) -> String { } /// Delete a sandbox by name, or all sandboxes when `all` is true. +#[allow(clippy::too_many_arguments)] // user-facing CLI command with explicit identity guards pub async fn sandbox_delete( server: &str, names: &[String], all: bool, + expected_id: Option<&str>, + expected_resource_version: Option, workspace: &str, tls: &TlsOptions, gateway: &str, ) -> Result<()> { + if (expected_id.is_some() || expected_resource_version.is_some()) && (all || names.len() != 1) { + return Err(miette!( + "--expected-id and --expected-resource-version require exactly one sandbox name" + )); + } + if expected_id.is_some_and(str::is_empty) { + return Err(miette!("--expected-id must not be empty")); + } + if expected_resource_version.is_some() && expected_id.is_none() { + return Err(miette!( + "--expected-resource-version requires --expected-id" + )); + } + if expected_resource_version == Some(0) { + return Err(miette!( + "--expected-resource-version must be greater than zero" + )); + } let mut client = grpc_client(server, tls).await?; let names_to_delete: Vec = if all { @@ -6657,7 +6680,7 @@ mod tests { use super::{ PolicyGetView, ProvisioningStep, build_sandbox_resource_limits, format_endpoint, format_log_line, git_sync_files, has_main_process_result, parse_cli_setting_value, - parse_credential_expiry_cli_value, parse_driver_config_json, + parse_credential_expiry_cli_value, parse_driver_config_json, parse_env_from_pairs, parse_secret_material_env_pairs, policy_revision_list_json, policy_revision_to_json, proto_execution_timeout, provisioning_timeout_message, ready_false_condition_message, resolve_from, rootfs_tar_sources_supported_for_gateway, sandbox_should_persist, @@ -6920,6 +6943,52 @@ mod tests { )); } + #[test] + fn parse_env_from_pairs_reads_named_and_same_name_environment_variables() { + let _named = EnvVarGuard::set("NAV_PARSE_ENV_FROM_NAMED", "named-value"); + let _same_name = EnvVarGuard::set("NAV_PARSE_ENV_FROM_SAME", "same-name-value"); + + let parsed = parse_env_from_pairs(&[ + "SANDBOX_NAMED=NAV_PARSE_ENV_FROM_NAMED".to_string(), + "NAV_PARSE_ENV_FROM_SAME".to_string(), + ]) + .expect("parse"); + assert_eq!( + parsed.get("SANDBOX_NAMED"), + Some(&"named-value".to_string()) + ); + assert_eq!( + parsed.get("NAV_PARSE_ENV_FROM_SAME"), + Some(&"same-name-value".to_string()) + ); + } + + #[test] + fn parse_env_from_pairs_rejects_missing_invalid_reserved_and_duplicate_keys() { + let _missing = EnvVarGuard::unset("NAV_PARSE_ENV_FROM_MISSING"); + let _present = EnvVarGuard::set("NAV_PARSE_ENV_FROM_PRESENT", "value"); + + for (input, expected) in [ + ("TARGET=NAV_PARSE_ENV_FROM_MISSING", "is not set"), + ("1BAD=NAV_PARSE_ENV_FROM_PRESENT", "key must match"), + ("TARGET=BAD-NAME", "source must match"), + ("OPENSHELL_RESERVED=NAV_PARSE_ENV_FROM_PRESENT", "reserved"), + ] { + let error = parse_env_from_pairs(&[input.to_string()]).expect_err("must reject"); + assert!( + error.to_string().contains(expected), + "unexpected error: {error}" + ); + } + + let error = parse_env_from_pairs(&[ + "TARGET=NAV_PARSE_ENV_FROM_PRESENT".to_string(), + "TARGET=NAV_PARSE_ENV_FROM_PRESENT".to_string(), + ]) + .expect_err("duplicate must reject"); + assert!(error.to_string().contains("duplicate --env-from")); + } + #[test] fn parse_secret_material_env_pairs_reads_value_from_named_environment_variable() { let _guard = EnvVarGuard::set("NAV_PARSE_SME_NAMED", "pem-material"); diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index 964559a8ad..6af85fe230 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -63,6 +63,7 @@ struct SandboxState { /// so a catalog lookup failure can be told apart from an empty catalog. fail_list_provider_profiles: Arc, deleted_names: Arc>>>, + delete_requests: Arc>>, create_requests: Arc>>, expose_service_requests: Arc>>, fail_delete_sandbox_message: Arc>>, @@ -370,6 +371,11 @@ impl OpenShell for TestOpenShell { request: tonic::Request, ) -> Result, Status> { let request = request.into_inner(); + self.state + .delete_requests + .lock() + .await + .push(request.clone()); self.state .deleted_names .lock() @@ -1504,6 +1510,10 @@ async fn deleted_names(server: &TestServer) -> Vec> { server.openshell.state.deleted_names.lock().await.clone() } +async fn delete_requests(server: &TestServer) -> Vec { + server.openshell.state.delete_requests.lock().await.clone() +} + async fn create_requests(server: &TestServer) -> Vec { server.openshell.state.create_requests.lock().await.clone() } @@ -1613,6 +1623,8 @@ async fn sandbox_delete_continues_after_entry_failure() { &server.endpoint, &["failing-sandbox".to_string(), "later-sandbox".to_string()], false, + None, + None, "default", &tls, "openshell", @@ -3059,6 +3071,16 @@ async fn run_cli_sandbox_create_with_xdg( xdg_dir: &TempDir, name: &str, extra_args: &[&str], +) -> std::process::Output { + run_cli_sandbox_create_with_xdg_and_env(server, xdg_dir, name, extra_args, &[]).await +} + +async fn run_cli_sandbox_create_with_xdg_and_env( + server: &TestServer, + xdg_dir: &TempDir, + name: &str, + extra_args: &[&str], + environment: &[(&str, &str)], ) -> std::process::Output { let mut cmd = tokio::process::Command::new(env!("CARGO_BIN_EXE_openshell")); for (key, _) in std::env::vars().filter(|(k, _)| k.starts_with("OPENSHELL_")) { @@ -3077,6 +3099,7 @@ async fn run_cli_sandbox_create_with_xdg( "--no-auto-providers", ]) .args(extra_args) + .envs(environment.iter().copied()) .env("XDG_CONFIG_HOME", xdg_dir.path()) .env("HOME", xdg_dir.path()) .env("OPENSHELL_PROVISION_TIMEOUT", "5") @@ -3095,6 +3118,47 @@ async fn run_cli_sandbox_create( run_cli_sandbox_create_with_xdg(server, &xdg_dir, name, extra_args).await } +async fn run_cli_sandbox_create_with_env( + server: &TestServer, + name: &str, + extra_args: &[&str], + environment: &[(&str, &str)], +) -> std::process::Output { + let xdg_dir = tempfile::tempdir().unwrap(); + prepare_cli_xdg(server, &xdg_dir); + run_cli_sandbox_create_with_xdg_and_env(server, &xdg_dir, name, extra_args, environment).await +} + +#[tokio::test] +async fn sandbox_create_env_from_reaches_request() { + let server = run_server().await; + let value = "qualification-value-not-in-argv"; + + let output = run_cli_sandbox_create_with_env( + &server, + "env-from-test", + &["--env-from", "SANDBOX_VALUE=HOST_VALUE", "--output=json"], + &[("HOST_VALUE", value)], + ) + .await; + assert!( + output.status.success(), + "sandbox create failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let requests = create_requests(&server).await; + let environment = &requests[0] + .spec + .as_ref() + .expect("spec should be present") + .environment; + assert_eq!( + environment.get("SANDBOX_VALUE").map(String::as_str), + Some(value) + ); +} + async fn run_cli_sandbox_template_create( server: &TestServer, name: &str, diff --git a/crates/openshell-core/src/paths.rs b/crates/openshell-core/src/paths.rs index 239d24b9ca..20196eaf48 100644 --- a/crates/openshell-core/src/paths.rs +++ b/crates/openshell-core/src/paths.rs @@ -57,6 +57,24 @@ pub fn openshell_state_dir() -> Result { Ok(xdg_state_dir()?.join("openshell")) } +/// Platform default for gateway audit files. Sink configuration and writing +/// remain portable; only the directory convention depends on the host. +pub fn gateway_audit_log_dir() -> PathBuf { + #[cfg(target_os = "windows")] + { + if let Some(directory) = std::env::var_os("ProgramData") { + return PathBuf::from(directory).join("OpenShell").join("logs"); + } + std::env::temp_dir().join("openshell").join("logs") + } + #[cfg(not(target_os = "windows"))] + { + openshell_state_dir() + .unwrap_or_else(|_| std::env::temp_dir().join("openshell")) + .join("logs") + } +} + /// Resolve the XDG data base directory. /// /// Returns `$XDG_DATA_HOME` if set, otherwise `$HOME/.local/share`. diff --git a/crates/openshell-core/src/policy.rs b/crates/openshell-core/src/policy.rs index af474325c5..66dde13ca6 100644 --- a/crates/openshell-core/src/policy.rs +++ b/crates/openshell-core/src/policy.rs @@ -113,6 +113,10 @@ impl TryFrom for SandboxPolicy { type Error = miette::Report; fn try_from(proto: ProtoSandboxPolicy) -> Result { + // UI capabilities are intentionally absent from the portable supervisor + // runtime. Non-Windows compute paths do not expose them, so even a + // schema-level UI allowance cannot grant a UI surface there. A capable + // compute driver consumes the typed proto directly. // In cluster mode we always run with proxy networking so all egress // can be evaluated by OPA. let network = NetworkPolicy { @@ -192,6 +196,7 @@ impl From for ProcessPolicy { #[cfg(test)] mod tests { use super::*; + use crate::proto::{UiClipboardAccess, UiPolicy}; #[test] fn try_from_maps_known_compatibility_values() { @@ -233,4 +238,26 @@ mod tests { assert!(!is_valid_landlock_compatibility("nope")); assert!(!is_valid_landlock_compatibility("BestEffort")); } + + #[test] + fn portable_runtime_does_not_activate_ui_allowances() { + let converted = SandboxPolicy::try_from(ProtoSandboxPolicy { + version: 1, + ui: Some(UiPolicy { + allow_graphical_ui: true, + clipboard: UiClipboardAccess::All as i32, + allow_input_injection: true, + }), + ..Default::default() + }) + .expect("portable policy conversion succeeds"); + + assert_eq!(converted.version, 1); + assert!(matches!(converted.network.mode, NetworkMode::Proxy)); + assert!(converted.network.proxy.is_some()); + assert!(converted.filesystem.read_only.is_empty()); + assert!(converted.filesystem.read_write.is_empty()); + assert!(converted.process.run_as_user.is_none()); + assert!(converted.process.run_as_group.is_none()); + } } diff --git a/crates/openshell-core/src/provider_credentials.rs b/crates/openshell-core/src/provider_credentials.rs index 6988d1f4be..26bd5ab96b 100644 --- a/crates/openshell-core/src/provider_credentials.rs +++ b/crates/openshell-core/src/provider_credentials.rs @@ -399,6 +399,18 @@ impl ProviderCredentialState { .revision } + /// Whether this snapshot contains endpoint-bound material that must be + /// resolved by a network proxy rather than exposed to the child process. + #[must_use] + pub fn requires_proxy_resolution(&self) -> bool { + let inner = self + .inner + .read() + .expect("provider credential state poisoned"); + !inner.static_credential_bindings.is_empty() + || !inner.current.dynamic_credentials.is_empty() + } + /// Remove a key from the credential snapshot's child env. /// /// Used when a sandbox-side service (e.g., metadata server) fails to start diff --git a/crates/openshell-driver-docker/src/isolation.rs b/crates/openshell-driver-docker/src/isolation.rs index d921b8c348..549c71c4de 100644 --- a/crates/openshell-driver-docker/src/isolation.rs +++ b/crates/openshell-driver-docker/src/isolation.rs @@ -132,6 +132,7 @@ impl DockerBoundarySpec { }, tls: self.supervisor_tls, host_gateway_ip: self.host_gateway_ip, + direct_proxy: None, resource_claims, outer_fence, }, diff --git a/crates/openshell-driver-kubernetes/src/isolation.rs b/crates/openshell-driver-kubernetes/src/isolation.rs index 6a94e6abaa..ba5343463d 100644 --- a/crates/openshell-driver-kubernetes/src/isolation.rs +++ b/crates/openshell-driver-kubernetes/src/isolation.rs @@ -289,6 +289,7 @@ impl KubernetesSandboxRuntimeBoundarySpec { }, tls: self.supervisor_tls, host_gateway_ip: self.host_gateway_ip, + direct_proxy: None, resource_claims, outer_fence, }, diff --git a/crates/openshell-driver-mxc/Cargo.toml b/crates/openshell-driver-mxc/Cargo.toml index 895d7c8d4c..d16d40da45 100644 --- a/crates/openshell-driver-mxc/Cargo.toml +++ b/crates/openshell-driver-mxc/Cargo.toml @@ -27,16 +27,20 @@ tokio-stream = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } base64 = { workspace = true } +rand = { workspace = true } tracing = { workspace = true } thiserror = { workspace = true } uuid = { workspace = true } -# ETW/TDH real-time consumer and host CONNECT proxy integration. +# ETW/TDH real-time consumer. [target.'cfg(target_os = "windows")'.dependencies] -openshell-supervisor-network = { path = "../openshell-supervisor-network" } +openshell-mxc-boundary = { path = "../openshell-mxc-boundary" } +openshell-isolation-interface = { path = "../openshell-isolation-interface" } +openshell-sandbox-backend = { path = "../openshell-sandbox-backend" } windows = { workspace = true } [dev-dependencies] +anyhow = { workspace = true } tokio = { workspace = true } # tempfile is not a workspace dependency; 3.27 is already resolved in Cargo.lock. tempfile = "3" diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index e5276ef4fe..d777fc7c13 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -1,8 +1,22 @@ # openshell-driver-mxc -OpenShell compute driver backed by **Microsoft MXC** (`wxc-exec`) on Windows. - -## Design +The Windows-only MXC compute driver runs each workload in a Microsoft MXC +ProcessContainer while preserving OpenShell's standard RFC 0012 runtime split: + +```text +gateway / MXC driver + | + | gateway authentication and policy + v +openshell-supervisor --role=isolation-backend (host) + | + | generation-scoped TLS + sandbox JWT + v +openshell-windows-sandbox (ProcessContainer) + | + v +workload +``` Caller driver config is disabled by default, so command-based MXC workflows need explicit administrator opt-in. Host filesystem grants have no trusted @@ -11,190 +25,142 @@ See [resource admission configuration](../../docs/how-it-works/gateways/configur for the independent controls and the security consequences of opting out. This driver implements the gateway's ordinary in-process `ComputeDriver` -contract and is linked into `openshell-gateway`. It sets -`driver_reports_runtime_readiness`, so the gateway accepts driver-reported -readiness without a supervisor session. The canonical create-time -`SandboxPolicy` is carried by `DriverSandboxSpec.policy`. `process_container` launches a one-shot -AppContainer and is the default. The opt-in `isolation_session` backend uses the -state-aware `provision` → `start` → `exec` → `stop` → `deprovision` lifecycle. -The driver launches and monitors the configured workload itself and self-reports -readiness; there is no in-sandbox supervisor or `ConnectSupervisor` relay. - -## Capability Matrix - -| Capability | MXC driver | Closing it requires | -|---|---|---| -| Filesystem policy (read-write / read-only grants) | ✅ provision-time AppContainer shares | — | -| Governed egress (CONNECT proxy + OPA + L7) | Available behind `egress_proxy` on `process_container`; the driver starts a per-sandbox host CONNECT proxy, generates HTTPS MITM trust material, and injects the CA bundle into the sandbox process env | Gateway event-bus wiring follow-on | -| Network policy | Split into MXC `network.proxy` + trimmed OpenShell policy on `process_container`; `isolation_session` still rejects network config | MXC feedback item M1 for persistent sessions | -| Network middleware | ❌ rejected before launch because the MXC host proxy does not receive the gateway middleware registry | Gateway middleware-registry injection | -| Process policy (seccomp, uid/gid) | ❌ host-side governance design; OS isolation only | not pursued | -| Interactive exec/connect/forward | ❌ exec runs in-driver, no client attach | gateway interactive-exec surgery (follow-on) | -| Bundled agent image | ❌ no OCI image; relies on Windows host install | — | -| Restart durability | ❌ in-memory registry; restart orphans live sessions | follow-on | -| Concurrent sandboxes | ⚠️ isolation_session v1 is single-session | MXC backend feature | - -The filesystem enforcement proof has two paths: - -- A write to a path granted by the sandbox policy succeeds. -- A `process_container` write outside the sandbox policy fails with Windows access denied, and the driver reports the failed workload. - -## Configuration (`[openshell.drivers.mxc]`) - -Gateway configuration contains only host runtime settings: +contract and is linked into `openshell-gateway`. Runtime readiness comes from +the authenticated host supervisor session, not a driver-reported condition. +The resolved create-time `SandboxPolicy` is carried by +`DriverSandboxSpec.policy`. The driver provisions a one-shot ProcessContainer, +launches the Windows boundary and separate host supervisor, and monitors both. +The boundary launches the workload only after authenticated confirmation. + +The driver resolves its gateway endpoint and TLS server-name defaults from +generic gateway inputs. The host supervisor authenticates its gateway session +and retains main-process attachment independently of the optional Unix SSH +adapter. TCP readiness remains gated by authenticated session acceptance. + +Windows boundary execution is implemented by the `openshell-mxc-boundary` +library behind the dedicated `openshell-windows-sandbox.exe`. The compute driver does not +embed the supervisor, and the sandbox does not link the compute driver. +Dynamic forwarding uses the shared protocol stream, not a dedicated relay +executable or a reverse connection to a new gateway listener. + +## Enforcement boundaries + +The current port is not runtime-qualified against main's outer-fence contract. +Broad host-loopback access does not establish `NoUnmanagedEgressPath`, and +live access revocation has no verified native evidence. The driver therefore +does not publish those guarantees, and main's confirmation gate prevents +workload activation. Configuration intent must not be promoted to enforcement +proof. A qualified PSEC host is necessary but does not by itself close these +implementation gaps. + +- MXC supplies the default-deny filesystem fence, AppContainer token, UI + policy, and loopback-only network fence. +- `openshell-windows-sandbox` consumes its bootstrap files before launching untrusted + code, authenticates the paired supervisor, and terminates workloads when an + authenticated supervisor cannot recover within the reconnect deadline. +- The host supervisor owns an authenticated per-generation explicit proxy. + Missing or cross-sandbox credentials receive HTTP 407 before policy + evaluation. MXC denies direct Internet egress. +- The current explicit-proxy path attributes traffic to the admitted main + workload binary. It does not distinguish descendant processes. MXC process + policy must therefore prevent an untrusted allowed child binary from + inheriting broader per-binary network rights. +- The loopback fence permits `127.0.0.1/32`; it does not isolate unrelated host + services bound to that address. Treat the gateway host as trusted. +- Host supervisor tokens and descriptors live beneath an owner-only Windows + DACL. Boundary bootstrap secrets live in the ProcessContainer staging path + and are deleted before workload launch. + +## Configuration + +The packaged `openshell-supervisor.exe` and `openshell-windows-sandbox.exe` default to +siblings of `openshell-gateway.exe`. Override their paths for development +builds. ```toml [openshell.drivers.mxc] -wxc_exec_path = "C:\\path\\to\\wxc-exec.exe" -# Default: process_container. isolation_session is grant-only and opt-in. +wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe" +supervisor_binary_path = "C:\\OpenShell\\openshell-supervisor.exe" +sandbox_binary_path = "C:\\OpenShell\\openshell-windows-sandbox.exe" +# Defaults to %LOCALAPPDATA%\OpenShell\mxc. +state_dir = "C:\\Users\\operator\\AppData\\Local\\OpenShell\\mxc" +# Empty uses the gateway's loopback listener and TLS mode. +grpc_endpoint = "" backend = "process_container" -default_configuration_id = "composable" pc_least_privilege = false pc_capabilities = [] -# Pattern-C governed egress. The address is a loopback seed; each sandbox -# receives a unique ephemeral proxy port. -egress_proxy = false -egress_proxy_addr = "" +pc_allow_local_network = true +pc_minimal_env = false debug = false etw_audit = false ``` -When `egress_proxy` is enabled, `egress_proxy_addr` must be a loopback -`IP:PORT` seed. The driver preserves the configured IP and allocates a unique -ephemeral port for each sandbox's `network.proxy` redirect. +Only `process_container` supports this architecture. `isolation_session` is +rejected during sandbox validation. + +Legacy configurations may retain `default_configuration_id`; it remains unused +by ProcessContainer and does not enable IsolationSession. New configurations +should omit that field. -Supply workload settings for each sandbox. The public config is keyed by driver name; the gateway forwards only the inner `mxc` object to the driver: +Supply the workload command and working directory per sandbox: ```powershell -$config = '{"mxc":{"command":["cmd","/c","echo hello > C:\\\\work\\\\demo\\\\hello.txt"],"cwd":"C:\\\\work\\\\demo"}}' -openshell sandbox create --name mxc-demo --policy demo.yaml ` - --driver-config-json $config --env MODE=demo --no-tty +$config = '{"mxc":{"command":["C:\\Windows\\System32\\cmd.exe","/d","/c","echo hello"],"cwd":"C:\\work"}}' +openshell sandbox create --name mxc-demo --policy policy.yaml ` + --driver-config-json $config --no-tty ``` -The `command` array is required and preserves Windows argument boundaries. `cwd` is optional. Environment variables come from the standard sandbox and template environment maps; the driver never copies values from the gateway host environment. - -The host CONNECT proxy enforces network policy when governed egress is enabled. -Live policy replacement or merge updates remain unsupported; delete and recreate -the sandbox to apply a different policy. - -When `etw_audit` is enabled, each gateway process owns a distinct real-time ETW -session named from the stable `OpenShell-MXC-ETW` prefix, its process ID, and a -per-start discriminator. Starting another gateway never stops an existing -gateway's capture. Graceful shutdown stops the session by its owned handle. A -force-killed gateway can leave a stale session; the audit example removes only -matching sessions whose encoded owner process is no longer running. - -The gateway-local OCSF JSONL sink is available only for the Windows/MXC path -and is opt-in. Set `OPENSHELL_OCSF_JSON=1` to enable it and optionally set -`OPENSHELL_OCSF_LOG_DIR` to override its `%PROGRAMDATA%\OpenShell\logs` default. -Other gateway deployments do not initialize this local file sink. - -The ETW callback uses a non-blocking queue capped at 4,096 records and 16 MiB -of copied event data. Records that exceed either limit are dropped instead of -blocking the ETW pump or growing gateway memory. The gateway emits an immediate -warning identifying the audit coverage gap and rate-limits follow-up warnings -to once every 30 seconds while overload continues. - -Audit attribution bootstraps only when the driver-owned `wxc-exec` PID and its -kernel process start key both match the values attached to the ETW record; -command text is never an ownership key. This generation key prevents a recycled -PID from inheriting the previous process's attribution regardless of delivery -delay. The process monitor retires the live PID at exit. Established identity, -activity, and correlation-vector links remain available for five seconds so -already in-flight ETW records can arrive, but retired PID evidence cannot resolve -them. Records without matching generation evidence remain unattributed. - -## Prerequisites (live runs) - -- Windows 11 Insider build ≥ 26300.8553 -- `IsoSessionApp.dll` present and registered -- `wxc-exec.exe` built with `--features isolation_session` -- Any enforced App Control policy allows both `openshell-gateway.exe` and - `openshell.exe`. Diagnose executable blocks with event 3077 in the - `Microsoft-Windows-CodeIntegrity/Operational` log. - -For off-box smoke tests against the in-process mock shim (no `wxc-exec`, -no isolation session needed), set `OPENSHELL_MXC_MOCK_WXC=1`. - -## Policy mapping - -The production driver maps the typed `SandboxPolicy` carried by the standard -driver request to MXC configuration before it inserts a registry entry or -invokes `wxc-exec`. Mapping failure therefore returns from `CreateSandbox` -without leaving a partial sandbox. There is no in-process policy side channel -or MXC-specific gateway composition variant. - -When `egress_proxy` is enabled, `EmbeddedPolicyMapper` uses `split_policy` -instead: MXC receives filesystem grants plus a loopback `network.proxy` -redirect, and the driver starts a host CONNECT proxy from the trimmed -network-only `SandboxPolicy`. Policies containing `network_middlewares` are -rejected synchronously until this host-proxy path can receive the gateway's -built-in and remote middleware registry. The proxy uses the configured agent -command as the static sandbox process identity because MXC does not expose -Linux-style procfs socket ownership. For HTTPS L7 inspection, the host proxy generates a -per-sandbox CA and injects `NODE_EXTRA_CA_CERTS`, `DENO_CERT`, `SSL_CERT_FILE`, -`REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, and `GIT_SSL_CAINFO` into the agent -process env. It does not add the generated CA directory to MXC read-only grants: -released `wxc-exec` BaseContainer builds require `WRITE_DAC` on every such -grant and reject the user-owned proxy temp directory. Instead, the driver adds -the sandbox-unique directory as an internal read-write share so HTTPS clients -can read the injected paths. The directory contains only public CA certificates; -the ephemeral CA private key remains in the host proxy's memory. The driver -seeds only `SYSTEMROOT`, `WINDIR`, `PATH`, `COMSPEC`, and `LOCALAPPDATA` from the -gateway host before applying sandbox and TLS overrides, so required Windows -bootstrap values remain available without exposing the gateway's full -environment. The development export surface remains the -[`policy-to-mxc`](examples/policy-to-mxc.rs) example; there is no production -`openshell policy export-mxc` subcommand yet. - -If governed egress is disabled, any network rule fails closed rather than launching without an enforcement path. - -Parity and matrix tests under [`tests/`](tests/) cover the mapper on the Windows MSVC lane. The driver performs this mapping automatically; there is no separate policy-export command or example. - -## Packaging the demo for the demo box - -Use [`examples/package-demo.ps1`](examples/package-demo.ps1) to assemble -the gateway EXE, CLI EXE, runtime DLLs (`libz3.dll`), `demo.yaml`, the -gateway config, and the runbook into one folder, then copy that folder to -the demo Windows host and follow `mxc-demo-runbook.md` inside it. The -script prints a SHA256 manifest so the operator can sanity-check what -landed before moving it. - -## Real-MXC test lane - -Three tasks drive real `wxc-exec.exe` hardware; all are **skip-safe** — any test -or scenario that requires an absent binary or backend prints a SKIP reason and -exits 0 rather than failing. - -| Task | What it runs | When to use | -|---|---|---| -| `windows:test:mxc-real:x64` | `tests/wxc_exec_real.rs` — Tier-2 invoker tests with `--ignored --test-threads=1`, including an HTTPS request through the host proxy | Pre-merge on any Windows host that has `wxc-exec`; dry-run tests always pass; enforcement tests probe-gate themselves | -| `windows:e2e:mxc` | `examples/run-mxc-e2e.ps1` — Tier-3 scenario runner, real binary, probe-gated | Demo box / nightly; needs the gateway + CLI binaries in the script directory | -| `windows:e2e:mxc:mock` | Same runner with `-Mock` — wiring-only, no real `wxc-exec` needed | Any Windows host (CI, dev machine); validates wiring and the network-reject scenario | - -**Probe script:** `examples/probe-mxc-host.ps1` is an operator/CI preflight that emits a JSON capability report -(OS build, wxc-exec path/version, dry-run exit code, per-backend trial result, -and a `verdicts` object). Run it before the real-MXC lane to understand what -will PASS vs SKIP on a given host: - -The probe uses a unique, user-owned Windows temp directory for every run. -MXC treats config paths literally (it does not expand `%TEMP%`), and the -per-run directory keeps AppContainer+DACL fallback mutations narrowly scoped. +The command is required. The working directory is required because it contains +the generation-scoped bootstrap staging directory. Environment belongs in +`--env` or `--env-from`, not gateway configuration. -```powershell -powershell -NoProfile -ExecutionPolicy Bypass ` - -File crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 -``` +When gateway TLS is enabled, configure the gateway-owned `guest_tls_ca`. +The host supervisor verifies the gateway certificate with that CA and uses +main's launch authentication bundle. Gateway client certificates and keys are +not required; driver-owned guest TLS fields are rejected. -**Skip semantics:** tests in `wxc_exec_real.rs` are marked -`#[ignore = "requires real wxc-exec"]` — the standard `windows:test:x64` suite -never runs them. `OPENSHELL_WXC_EXEC_PATH` overrides the default -`C:\mxc\wxc-exec.exe` lookup. See `docs4gtb/mxc-box-capabilities.md` for the -empirical capability snapshot of the development box (build 26200, processcontainer -velocity keys not enabled, isolation_session absent). +## Capabilities -## Deferred work +| Capability | Status | +|---|---| +| Filesystem and UI policy | Mapped to the MXC ProcessContainer fence | +| Network policy and provider credentials | Standard host supervisor proxy; proxy-aware workloads only | +| Exec, signals, retained output | Authenticated Sandbox Protocol; ConPTY resize is not yet supported | +| Dynamic forwarding | Standard supervisor `ForwardTcp` path through sandbox loopback connect | +| ETW/OCSF audit | Optional Windows Sandboxing ETW consumer | +| Gateway restart recovery | Not yet supported; live MXC generations remain in-memory | + +## Validation + +Run the Windows build lane on a native Windows MSVC host: + +```powershell +mise run --skip-tools windows:check:x64 +mise run --skip-tools windows:lint:x64 +mise run --skip-tools windows:build:x64 +mise run --skip-tools windows:test:mxc-real:x64 +mise run --skip-tools windows:e2e:mxc +``` -- **Interactive exec/connect/forward** — gateway interactive-exec surgery (follow-on) -- **Restart durability** (deprovision orphaned sessions on startup) → follow-on -- **GPU passthrough** → not pursued in host-side-governance design +The real-MXC tests are skip-safe when `wxc-exec.exe` or the required host +capabilities are absent. A complete integration run still requires a qualified +Windows MXC host; cross-compilation validates code shape but cannot validate +ProcessContainer networking or DACL behavior. + +The real runtime requires native ProcessContainer directional egress and ingress +host-loopback support, reported by `wxc-exec.exe --probe`. AppContainer fallback +cannot supply these guarantees. Count test-internal `SKIP` messages separately +from Cargo's passed count. + +The E2E runner uses native release artifacts (override with `-BinaryDir`), OpenSSL +on PATH for disposable Ed25519 keys, and per-run writable fixtures under +`target/windows-e2e-results`. It preserves the tracked gateway TOML and isolates +CLI registration via `XDG_CONFIG_HOME`. Signing keys have an owner-only DACL, +remain outside result bundles, and are removed unless `-KeepRunning` is requested. + +E2E runs real MXC only: there is no mock mode, and an inherited +`OPENSHELL_MXC_MOCK_WXC=1` is rejected. Any skipped selected scenario makes the +run incomplete and exits non-zero; a passing exit requires all selected scenarios +to execute successfully. Unit-test mocks are separate from E2E coverage. +`network-policy` checks admission and workload execution, not real network +enforcement. All-skipped real runs report `INCOMPLETE`, not `PASS`. diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/network-reject.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/network-reject.yaml index e5529eaeb6..517885d4e7 100644 --- a/crates/openshell-driver-mxc/examples/e2e-policies/network-reject.yaml +++ b/crates/openshell-driver-mxc/examples/e2e-policies/network-reject.yaml @@ -1,15 +1,9 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -# network-reject.yaml — Network policy rejection scenario. -# -# A filesystem grant plus a network_policies rule. The driver rejects sandbox -# create at map time with invalid_argument naming the network rule on every -# backend until MXC has a bound, enforcing egress path. -# -# This scenario requires NO live backend — it passes even on this box and in -# mock mode because the rejection happens in the policy mapper before wxc-exec -# is invoked. It is the only scenario that never SKIPs. +# A filesystem grant plus a supervisor-owned network policy. The historical +# filename is retained for existing demo packages. The network-policy scenario +# checks admission and execution, not HTTP or network enforcement. version: 1 filesystem_policy: @@ -20,10 +14,12 @@ filesystem_policy: network_policies: test_rule: - name: test-network-reject + name: test-network-policy endpoints: - host: api.example.com port: 443 protocol: rest enforcement: enforce access: read-only + binaries: + - path: "C:/Windows/System32/cmd.exe" diff --git a/crates/openshell-driver-mxc/examples/install-nodejs-openclaw.ps1 b/crates/openshell-driver-mxc/examples/install-nodejs-openclaw.ps1 new file mode 100644 index 0000000000..8e05b9f3f4 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/install-nodejs-openclaw.ps1 @@ -0,0 +1,157 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# install-nodejs-openclaw.ps1 - fetch a pinned Node.js and the OpenClaw npm +# package, laid out exactly how run-openclaw-forward-test.ps1 expects them. +# +# This is a PREREQUISITE step, not part of the test itself: run it once (or +# with -Force to re-fetch), then pass its printed -NodeExePath / +# -OpenClawInstallDir values straight through to run-openclaw-forward-test.ps1. +# If you already have a working Node.js + OpenClaw install elsewhere, you +# don't need this script at all -- just point run-openclaw-forward-test.ps1 at +# it directly. +# +# What it does: +# 1. Downloads the official Node.js Windows x64 zip build (no installer, no +# admin rights needed) for a pinned version, verifies its SHA256 against +# Node.js's published SHASUMS256.txt, and extracts it. +# 2. Uses that Node's bundled npm to install the "openclaw" package from the +# public npm registry into the same install directory (`npm install +# openclaw --prefix `), which lays it out at +# \node_modules\openclaw -- exactly what -OpenClawInstallDir expects. +# +# Usage: +# powershell -NoProfile -ExecutionPolicy Bypass -File .\install-nodejs-openclaw.ps1 +# powershell -NoProfile -ExecutionPolicy Bypass -File .\run-openclaw-forward-test.ps1 ` +# -WxcExecPath C:\mxc-kit\bin\wxc-exec.exe ` +# -NodeExePath C:\openshell-openclaw-install\node\node.exe ` +# -OpenClawInstallDir C:\openshell-openclaw-install\node_modules\openclaw +# +# Needs outbound internet access to nodejs.org and registry.npmjs.org. If +# your box only reaches the internet through a corporate proxy, set the usual +# HTTP_PROXY/HTTPS_PROXY env vars before running this script -- both +# Invoke-WebRequest and npm respect them. + +[CmdletBinding()] +param( + # Must be a DIRECT CHILD of a drive root (e.g. C:\openshell-openclaw-install, + # not C:\work\openshell-openclaw-install) for the SAME reason + # run-openclaw-forward-test.ps1's -ShareDir must be: nothing here actually + # runs inside the AppContainer, but keeping this path shape consistent + # avoids surprises if you ever point -ShareDir at this same location. + [string] $InstallDir = "C:\openshell-openclaw-install", + # Pinned to the version this package's OpenClaw scenario was validated + # against (observed working: Node.js v22.22.3). Override if you need a + # different one, but that combination is untested by this package. + [string] $NodeVersion = "22.22.3", + # Pinned to the version this package's OpenClaw scenario was validated + # against (the package actually exercised by run-openclaw-forward-test.ps1 + # across this repo's live testing). Override (or pass "" for whatever + # "npm install openclaw" resolves to latest at run time) for ad hoc testing, + # but that's untested by this package. + [string] $OpenClawVersion = "2026.7.1", + # Re-download/re-install even if InstallDir already looks populated. + [switch] $Force +) + +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" # Invoke-WebRequest is dramatically faster with the progress bar off. + +function Step([string]$m) { Write-Host "`n=== $m ===" -ForegroundColor Cyan } +function Info([string]$m) { Write-Host " $m" } +function Ok([string]$m) { Write-Host "[OK] $m" -ForegroundColor Green } +function Bad([string]$m) { Write-Host "[FAIL] $m" -ForegroundColor Red } + +$installDirNorm = $InstallDir.TrimEnd('\','/').Replace('/', '\') +$nodeDir = Join-Path $installDirNorm "node" +$nodeExe = Join-Path $nodeDir "node.exe" +$npmCmd = Join-Path $nodeDir "npm.cmd" +$openClawDir = Join-Path $installDirNorm "node_modules\openclaw" +$downloadDir = Join-Path $installDirNorm "_download" + +try { + Step "Node.js v$NodeVersion for win-x64" + if ((Test-Path $nodeExe) -and -not $Force) { + $existing = & $nodeExe --version + Info "already installed at $nodeExe (version $existing) -- pass -Force to re-fetch" + } else { + New-Item -ItemType Directory -Force $downloadDir | Out-Null + $distBase = "https://nodejs.org/dist/v$NodeVersion" + $zipName = "node-v$NodeVersion-win-x64.zip" + $zipPath = Join-Path $downloadDir $zipName + + Info "downloading $distBase/$zipName" + Invoke-WebRequest -Uri "$distBase/$zipName" -OutFile $zipPath + + Info "verifying SHA256 against $distBase/SHASUMS256.txt" + $shasums = Invoke-WebRequest -Uri "$distBase/SHASUMS256.txt" -UseBasicParsing | Select-Object -ExpandProperty Content + $expectedLine = ($shasums -split "`n") | Where-Object { $_ -match [regex]::Escape($zipName) } | Select-Object -First 1 + if (-not $expectedLine) { throw "no SHASUMS256.txt entry found for $zipName -- refusing to install an unverified download" } + $expectedHash = ($expectedLine -split '\s+')[0].Trim().ToLowerInvariant() + $actualHash = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($expectedHash -ne $actualHash) { + throw "SHA256 mismatch for $zipName`n expected: $expectedHash`n actual: $actualHash`nDeleting the download; do not use it." + } + Ok "SHA256 verified: $actualHash" + + Info "extracting to $nodeDir" + if (Test-Path $nodeDir) { Remove-Item -Recurse -Force $nodeDir } + $extractStaging = Join-Path $downloadDir "extract" + if (Test-Path $extractStaging) { Remove-Item -Recurse -Force $extractStaging } + Expand-Archive -Path $zipPath -DestinationPath $extractStaging -Force + # The zip's own top-level entry is "node-v-win-x64\..."; flatten + # that one level so callers get a stable \node\node.exe path + # regardless of version. + $innerDir = Get-ChildItem $extractStaging -Directory | Select-Object -First 1 + if (-not $innerDir) { throw "unexpected zip layout: no top-level directory found after extraction" } + Move-Item $innerDir.FullName $nodeDir + Remove-Item -Recurse -Force $extractStaging, $zipPath -ErrorAction SilentlyContinue + + if (-not (Test-Path $nodeExe)) { throw "extraction completed but $nodeExe is missing -- unexpected zip layout" } + $installedVersion = & $nodeExe --version + Ok "installed node.exe ($installedVersion) at $nodeExe" + } + + Step "OpenClaw (npm)" + if (-not (Test-Path $npmCmd)) { throw "npm.cmd not found next to node.exe at $npmCmd -- Node.js install looks incomplete" } + # This Node.js install is a standalone zip extraction, not the installer -- + # nothing put it on PATH. npm spawns pre/postinstall scripts (OpenClaw and + # some of its native-addon deps have them) via cmd.exe, and those scripts + # invoke bare "node"; without $nodeDir on PATH that fails with "'node' is + # not recognized...", which in turn makes npm's own cleanup of the + # half-installed tree fail with a wall of unrelated-looking EPERM rmdir + # warnings. Prepend $nodeDir to PATH for this call only. + $env:Path = "$nodeDir;$env:Path" + if ((Test-Path (Join-Path $openClawDir "openclaw.mjs")) -and -not $Force) { + Info "already installed at $openClawDir -- pass -Force to re-install" + } else { + $pkgSpec = if ($OpenClawVersion) { "openclaw@$OpenClawVersion" } else { "openclaw" } + Info "npm install $pkgSpec --prefix $installDirNorm" + # --no-save: this prefix dir isn't a real npm project (no package.json we + # want npm managing); we just want node_modules\openclaw populated. + & $npmCmd install $pkgSpec --prefix $installDirNorm --no-save --no-audit --no-fund + if ($LASTEXITCODE -ne 0) { throw "npm install failed (exit $LASTEXITCODE)" } + if (-not (Test-Path (Join-Path $openClawDir "openclaw.mjs"))) { + throw "npm install succeeded but $openClawDir\openclaw.mjs is missing -- is 'openclaw' really the right package name/layout on the registry you're using?" + } + Ok "installed OpenClaw at $openClawDir" + } + + Remove-Item -Recurse -Force $downloadDir -ErrorAction SilentlyContinue + + Step "Done" + Write-Host "" + Write-Host "Pass these to run-openclaw-forward-test.ps1:" -ForegroundColor Yellow + Write-Host " -NodeExePath `"$nodeExe`"" + Write-Host " -OpenClawInstallDir `"$openClawDir`"" + Write-Host "" + Write-Host "Example:" -ForegroundColor Yellow + Write-Host " powershell -NoProfile -ExecutionPolicy Bypass -File .\run-openclaw-forward-test.ps1 ``" + Write-Host " -WxcExecPath C:\mxc-kit\bin\wxc-exec.exe ``" + Write-Host " -NodeExePath `"$nodeExe`" ``" + Write-Host " -OpenClawInstallDir `"$openClawDir`"" +} +catch { + Bad $_.Exception.Message + exit 1 +} diff --git a/crates/openshell-driver-mxc/examples/mxc-gateway.toml b/crates/openshell-driver-mxc/examples/mxc-gateway.toml index 4fbd2c41f5..7c492fd6ac 100644 --- a/crates/openshell-driver-mxc/examples/mxc-gateway.toml +++ b/crates/openshell-driver-mxc/examples/mxc-gateway.toml @@ -1,6 +1,9 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 +[openshell] +version = 2 + # MXC gateway runtime configuration. # # Commands, working directories, and environment variables are sandbox-scoped. @@ -12,18 +15,18 @@ # smoke tests (set OPENSHELL_MXC_MOCK_WXC=1 instead). wxc_exec_path = "C:\\mxc\\wxc-exec.exe" -# process_container is the default because AppContainer enforces default-deny -# filesystem access. isolation_session is an explicit grant-only compatibility -# mode and does not deny access to paths omitted from the sandbox policy. +# The RFC 0012 MXC runtime requires ProcessContainer. backend = "process_container" +# The packaged runtime binaries default to siblings of openshell-gateway.exe. +# Override these paths when running from a different development layout. +# supervisor_binary_path = "C:\\path\\to\\openshell-supervisor.exe" +# sandbox_binary_path = "C:\\path\\to\\openshell-windows-sandbox.exe" + # process_container only: request a Less-Privileged AppContainer. # pc_least_privilege = false # process_container only: AppContainer capabilities to grant. # pc_capabilities = [] -# isolation_session only. Never use "small" (known OS bug). -default_configuration_id = "composable" - # Enable --debug on wxc-exec invocations. debug = false diff --git a/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml b/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml index a828264a5e..ac42b57fd9 100644 --- a/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml +++ b/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml @@ -9,8 +9,8 @@ # Detection Finding [2004] — written to a durable JSONL log, just like the Linux # OCSF pipeline. # -# run-ocsf-audit.ps1 patches wxc_exec_path, backend, etw_audit and the -# egress-proxy switch into a disposable copy of this file. Workload command and +# run-ocsf-audit.ps1 patches wxc_exec_path, backend, and etw_audit into a +# disposable copy of this file. Workload command and # cwd are sandbox-scoped and passed separately through --driver-config-json. [openshell.drivers.mxc] @@ -22,19 +22,10 @@ wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe" # captures. (isolation_session is "dark" — it emits no provider events.) backend = "process_container" -default_configuration_id = "composable" - debug = false # Turn ON the Plane-A ETW -> OCSF audit consumer. This is the core of the example. etw_audit = true -# Per-sandbox governed egress. Enabling this makes the driver start a host CONNECT -# proxy and hand MXC a `network.proxy` redirect, which is what makes MXC emit the -# SandboxProxyConfigured event — the config event mapped to OCSF CONFIG [5019] -# that completes full event coverage. Requires backend = process_container and a -# loopback (127.0.0.1) seed address; the driver allocates a unique ephemeral port -# per sandbox from this seed. Run-ocsf-audit.ps1 disables this when passed -# -NoProxy. -egress_proxy = true -egress_proxy_addr = "127.0.0.1:18080" +# The driver always provisions the authenticated host supervisor proxy and +# generation-scoped Sandbox Protocol transport. diff --git a/crates/openshell-driver-mxc/examples/ocsf-audit.yaml b/crates/openshell-driver-mxc/examples/ocsf-audit.yaml index 13e7cd571e..784ac77fb0 100644 --- a/crates/openshell-driver-mxc/examples/ocsf-audit.yaml +++ b/crates/openshell-driver-mxc/examples/ocsf-audit.yaml @@ -7,9 +7,9 @@ # else is default-deny. run-ocsf-audit.ps1 copies this policy into the result # bundle and replaces the default grant with -ShareDir for that run. # -# No network_policies block is needed here: the per-sandbox egress proxy is driven -# by `egress_proxy = true` in mxc-ocsf-audit.toml (that is what makes MXC emit the -# SandboxProxyConfigured event we map to OCSF), not by a policy rule. +# No network policy is needed for this filesystem-focused audit scenario. The +# RFC 0012 MXC runtime always provisions its authenticated control and proxy +# listeners independently of workload network authorization. version: 1 filesystem_policy: diff --git a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 index 46536ff4a3..57f76979f5 100644 --- a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 +++ b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 @@ -5,9 +5,9 @@ # # Runs a table of policy scenarios against the OpenShell MXC driver, emits # per-scenario PASS/FAIL/SKIP(reason), prints a summary table, and exits non-zero -# only on FAIL. +# unless every selected scenario passes against real MXC. # -# Every run collects its logs into a timestamped results-e2e-\ folder and +# Every run collects its logs beneath target/windows-e2e-results and # zips it (mirrors the sibling run-*.ps1 scripts). The bundle contains the console # transcript, the per-scenario gateway stdout/stderr, the exact TOML rendered for # each scenario, the policy fixture used, and a summary.txt with the verdict table. @@ -16,9 +16,9 @@ # isolated. Workload command/cwd are supplied per sandbox through # --driver-config-json; they are never patched into gateway configuration. # -# Scoring (why we do NOT gate on `sandbox create` exit code): -# The ground truth is the on-disk artifact, so positive scenarios pass on -# artifact PRESENT and deny scenarios pass on the denied write being ABSENT. +# Scoring: +# Positive scenarios require successful creation and a workload artifact. +# Deny scenarios require absent denied writes plus execution evidence. # A CONTROL write proves the agent ran when the policy grants a writable path; # an empty policy instead requires explicit driver-launch evidence. # @@ -26,7 +26,6 @@ # # Usage: # powershell -NoProfile -ExecutionPolicy Bypass -File .\run-mxc-e2e.ps1 -WxcExecPath C:\mxc-kit\bin\wxc-exec.exe -# .\run-mxc-e2e.ps1 -Mock # wiring-only, no real backend # .\run-mxc-e2e.ps1 -Scenario fs-rw # single scenario # # Scenarios & expected verdicts: @@ -34,18 +33,18 @@ # fs-readonly - write to read-only dir is denied; control write succeeds. # fs-default-deny - ungranted write is denied after the agent launches. # processcontainer only. -# network-reject - network_policies rule makes sandbox create fail. +# network-policy - supervisor-owned network policy permits admission. [CmdletBinding()] param( - [string] $DemoDir = "C:\work\openshell-mxc-e2e", + [string] $DemoDir, + [string] $BinaryDir, [string] $WxcExecPath = "C:\mxc-kit\bin\wxc-exec.exe", [ValidateSet("isolation_session", "process_container")] [string] $Backend = "process_container", [string] $Scenario, [int] $Port = 17670, [string] $GatewayName = "openshell-mxc-e2e", - [switch] $Mock, [switch] $KeepRunning ) @@ -63,8 +62,22 @@ $here = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path } # front so the transcript (started inside the guarded region below) captures the # whole run, including pre-flight failures. $stamp = Get-Date -Format "yyyyMMdd-HHmmss" -$resultDir = Join-Path $here "results-e2e-$stamp" +$repoRoot = [IO.Path]::GetFullPath((Join-Path $here "../../..")) +$resultsRoot = Join-Path $repoRoot "target/windows-e2e-results" +$resultDir = Join-Path $resultsRoot "results-e2e-$stamp" New-Item -ItemType Directory -Force $resultDir | Out-Null +if (-not $DemoDir) { $DemoDir = Join-Path $resultDir "work" } +if (-not $BinaryDir) { + $targetRoot = if ($env:CARGO_TARGET_DIR) { $env:CARGO_TARGET_DIR } else { Join-Path $repoRoot "target" } + $arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() + $target = if ($arch -eq "Arm64") { "aarch64-pc-windows-msvc" } else { "x86_64-pc-windows-msvc" } + $BinaryDir = Join-Path $targetRoot "$target/release" +} +$savedEnv = @{} +foreach ($name in @("OPENSHELL_GATEWAY_CONFIG", "OPENSHELL_GATEWAY", "OPENSHELL_MXC_MOCK_WXC", "OPENSHELL_WXC_EXEC_PATH", "OPENSHELL_COMPUTE_DRIVER", "OPENSHELL_MXC_SHARE_DIR", "XDG_CONFIG_HOME")) { + $savedEnv[$name] = [Environment]::GetEnvironmentVariable($name, "Process") +} +$secretDir = Join-Path $resultsRoot "keys-$stamp-$([guid]::NewGuid().ToString('N'))" $transcriptStarted = $false function Step([string]$m) { Write-Host "`n=== $m ===" -ForegroundColor Cyan } @@ -139,13 +152,15 @@ function Invoke-NativeCaptured([string]$filePath, [string[]]$argumentList) { # --- Path variables ----------------------------------------------------------- -$gateway = Join-Path $here "openshell-gateway.exe" -$cli = Join-Path $here "openshell.exe" -$toml = Join-Path $here "mxc-gateway.toml" +$gateway = Join-Path $BinaryDir "openshell-gateway.exe" +$cli = Join-Path $BinaryDir "openshell.exe" +$tomlTemplate = Join-Path $here "mxc-gateway.toml" +$toml = Join-Path $resultDir "mxc-gateway.toml" $policyDir = Join-Path $here "e2e-policies" $cmdExe = "C:\Windows\System32\cmd.exe" $demoDirFwd = $DemoDir.Replace('\', '/') +$defaultDemoDir = "C:\work\openshell-mxc-e2e" $roSrc = "$DemoDir-ro-src" # matches e2e-policies/fs-readonly.yaml read_only path $denyProbe = "$DemoDir-deny-probe" # ungranted, NOT the share: used to prove default-deny @@ -165,13 +180,26 @@ $gwErrLog = $null function Render-Toml { $t = $tomlBase $t = [regex]::Replace($t, '(?m)^\s*#?\s*backend\s*=.*$', "backend = `"$Backend`"") - if (-not $Mock) { - $wxcLine = "wxc_exec_path = `"$(Esc $WxcExecPath)`"" - $t = [regex]::Replace($t, '(?m)^\s*#?\s*wxc_exec_path\s*=.*$', $wxcLine) - } + $wxcLine = "wxc_exec_path = `"$(Esc $WxcExecPath)`"" + $t = [regex]::Replace($t, '(?m)^\s*#?\s*wxc_exec_path\s*=.*$', $wxcLine) + $t += @" + +[openshell.gateway.auth] +allow_unauthenticated_users = true + +[openshell.gateway.gateway_jwt] +signing_key_path = '$(Join-Path $secretDir 'signing.pem')' +public_key_path = '$(Join-Path $secretDir 'public.pem')' +kid_path = '$(Join-Path $secretDir 'kid')' +gateway_id = 'mxc-e2e' +"@ Set-Content $toml -Value $t -Encoding UTF8 } +function Test-PortListening { + return @([Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties().GetActiveTcpListeners() | Where-Object { $_.Port -eq $Port }).Count -gt 0 +} + function Start-Gw { Remove-Item $gwLog, $gwErrLog -Force -ErrorAction SilentlyContinue # Ephemeral in-memory DB: this is a test harness, so it must NOT write sandbox @@ -193,7 +221,7 @@ function Start-Gw { Get-Content $gwLog, $gwErrLog -Encoding UTF8 -ErrorAction SilentlyContinue | ForEach-Object { Info $_ } throw "gateway exited early (code $($p.ExitCode)). See $gwLog." } - if (Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue) { + if (Test-PortListening) { return $p } Start-Sleep -Milliseconds 400 @@ -264,10 +292,15 @@ function Launch-Succeeded([string]$gwText) { # --- Backend probe ------------------------------------------------------------ function Probe-Backend([string] $backendName, [string] $wxc) { - if ($Mock) { return @{ Live = $true; Reason = "mock mode" } } if (-not (Test-Path $wxc)) { return @{ Live = $false; Reason = "wxc-exec not found at $wxc" } } if ($backendName -eq "process_container") { + $probeResult = Invoke-NativeCaptured $wxc @("--probe") + if ($probeResult.ExitCode -ne 0) { throw "MXC capability probe failed: $($probeResult.Output -join ' ')" } + $capabilities = ($probeResult.Output -join "`n") | ConvertFrom-Json + if (-not $capabilities.probes.baseContainerSupportsIngressHostLoopbackAllow) { + return @{ Live = $false; Reason = "native ProcessContainer host-loopback support unavailable (processmodel PSEC contract required)" } + } # Use a REAL directory + absolute cmd.exe: the canonical wxc-exec passes # cwd straight to CreateProcessW and does NOT expand %TEMP% (that yields # 0x8007010B "directory name is invalid"). @@ -368,11 +401,8 @@ try { # --- Pre-flight ----------------------------------------------------------- - if (-not $Mock) { - if ($env:OPENSHELL_MXC_MOCK_WXC -eq "1") { - throw "OPENSHELL_MXC_MOCK_WXC=1 is set but -Mock was not passed. " + - "Unset OPENSHELL_MXC_MOCK_WXC or pass -Mock." - } + if ($env:OPENSHELL_MXC_MOCK_WXC -eq "1") { + throw "OPENSHELL_MXC_MOCK_WXC=1 is set. E2E requires real MXC; unset this variable." } # -KeepRunning leaves the gateway up and breaks after the FIRST scenario (so the @@ -383,7 +413,7 @@ try { throw "-KeepRunning requires -Scenario: it stops after the first scenario, so a full-suite run would report PASS on partial results. Re-run with e.g. -Scenario fs-rw-positive-negative -KeepRunning." } - foreach ($f in @($gateway, $cli, $toml)) { + foreach ($f in @($gateway, $cli, $tomlTemplate, (Join-Path $BinaryDir 'openshell-supervisor.exe'), (Join-Path $BinaryDir 'openshell-windows-sandbox.exe'))) { if (-not (Test-Path $f)) { throw "Missing artifact: $f`nBuild first or run from a demo-package folder." } @@ -393,11 +423,25 @@ try { } # Capture the pristine TOML once; every scenario renders a fresh copy from this. - $tomlBase = Get-Content $toml -Raw + $tomlBase = Get-Content $tomlTemplate -Raw + $env:XDG_CONFIG_HOME = Join-Path $resultDir "cli-config" + $openssl = (Get-Command openssl -ErrorAction Stop).Source + New-Item -ItemType Directory $secretDir | Out-Null + $ownerSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value + & icacls.exe $secretDir /inheritance:r /grant:r "*$($ownerSid):(OI)(CI)F" | Out-Null + if ($LASTEXITCODE -ne 0) { throw "cannot protect disposable signing-key directory" } + foreach ($argsForKey in @( + @('genpkey', '-algorithm', 'ED25519', '-out', (Join-Path $secretDir 'signing.pem')), + @('pkey', '-in', (Join-Path $secretDir 'signing.pem'), '-pubout', '-out', (Join-Path $secretDir 'public.pem')) + )) { + $keyResult = Invoke-NativeCaptured $openssl $argsForKey + if ($keyResult.ExitCode -ne 0) { throw "disposable JWT key generation failed" } + } + Set-Content (Join-Path $secretDir 'kid') -Value 'mxc-e2e' -Encoding ASCII # --- Mode setup ----------------------------------------------------------- - Step "Pre-flight (mode=$(if ($Mock) {'MOCK'} else {'REAL'}), backend=$Backend)" + Step "Pre-flight (mode=REAL, backend=$Backend)" $cliProbe = Invoke-NativeCaptured $cli @("--version") $cliProbeText = ($cliProbe.Output -join "`n") if ($cliProbe.ExitCode -ne 0) { @@ -405,29 +449,20 @@ try { } Ok "CLI executable allowed: $($cliProbe.Output -join ' ')" - if ($Mock) { - $env:OPENSHELL_MXC_MOCK_WXC = "1" - Info "OPENSHELL_MXC_MOCK_WXC=1 - mock mode: enforcement simulated" - } else { - Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue - if (-not (Test-Path $WxcExecPath)) { - throw "wxc-exec not found at '$WxcExecPath'. Pass -WxcExecPath or use -Mock." - } - $env:OPENSHELL_WXC_EXEC_PATH = $WxcExecPath - Info "wxc-exec: $WxcExecPath" - } + Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue + $env:OPENSHELL_WXC_EXEC_PATH = $WxcExecPath + Info "wxc-exec: $WxcExecPath" $backendProbe = Probe-Backend -backendName $Backend -wxc $WxcExecPath if ($backendProbe.Live) { Ok "Backend '$Backend' is live: $($backendProbe.Reason)" } else { Warn "Backend '$Backend' is not live: $($backendProbe.Reason)" - Warn "Enforcement scenarios will SKIP; network-reject scenario will still run." + Warn "Runtime scenarios will SKIP; this is not a real-isolation PASS." } Step "Check gateway port $Port" - $busy = Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue - if ($busy) { throw "port $Port in use (pid $($busy.OwningProcess)). Stop stale gateway first." } + if (Test-PortListening) { throw "port $Port in use. Stop stale gateway first." } Ok "port $Port free" Step "Prepare DemoDir + read-only source + deny-probe dir" @@ -441,7 +476,7 @@ try { $env:OPENSHELL_MXC_SHARE_DIR = $DemoDir # --- Scenario definitions ------------------------------------------------- - # Kind: positive | deny | create-fail + # Kind: positive | deny # For deny: ControlTarget (granted, must be PRESENT) + DenyTarget (must be ABSENT). $allScenarios = @( @@ -468,10 +503,11 @@ try { Description = "empty policy; ungranted write denied" }, @{ - Name = "network-reject"; PolicyFile = Join-Path $policyDir "network-reject.yaml" + Name = "network-policy"; PolicyFile = Join-Path $policyDir "network-reject.yaml" SandboxId = "net" - Backends = "both"; Kind = "create-fail" - Description = "network_policies rule causes sandbox create to fail (no live backend needed)" + Backends = "both"; Kind = "positive" + PosTarget = (Join-Path $DemoDir "network-policy-result.txt") + Description = "supervisor-owned network policy is accepted and workload runs (not an egress assertion)" } ) @@ -493,15 +529,12 @@ try { Step "Scenario: $($sc.Name)" Info $sc.Description - # Backend gate (deny/positive scenarios need a live backend; create-fail does not). $skipReason = $null - if ($sc.Kind -ne "create-fail") { - $backendMatches = ($sc.Backends -eq "both") -or ($sc.Backends -eq $Backend) - if (-not $backendMatches) { - $skipReason = "scenario requires backend=$($sc.Backends); current backend=$Backend" - } elseif (-not $backendProbe.Live -and -not $Mock) { - $skipReason = "backend not live: $($backendProbe.Reason)" - } + $backendMatches = ($sc.Backends -eq "both") -or ($sc.Backends -eq $Backend) + if (-not $backendMatches) { + $skipReason = "scenario requires backend=$($sc.Backends); current backend=$Backend" + } elseif (-not $backendProbe.Live) { + $skipReason = "backend not live: $($backendProbe.Reason)" } if ($null -ne $skipReason) { Skip "$($sc.Name): $skipReason" @@ -515,6 +548,19 @@ try { continue } + # Render a disposable policy for every scenario. The source YAML + # intentionally carries the documented default paths, while + # -DemoDir is a supported override. Both the read-write path and + # the read-only sibling share the same default prefix, so one + # exact prefix substitution keeps their relative naming intact. + $policyUsed = Join-Path $resultDir "policy.$($sc.Name).yaml" + $policyText = Get-Content $sc.PolicyFile -Raw + $policyText = $policyText.Replace( + $defaultDemoDir.Replace('\', '/'), + $DemoDir.Replace('\', '/') + ) + Set-Content -Path $policyUsed -Value $policyText -Encoding UTF8 + # Per-scenario gateway logs land in the bundle under their own names. $gwLog = Join-Path $resultDir "gateway.$($sc.Name).log" $gwErrLog = Join-Path $resultDir "gateway.$($sc.Name).err.log" @@ -529,7 +575,7 @@ try { if ($sc.ControlTarget) { Remove-Item $sc.ControlTarget -Force -ErrorAction SilentlyContinue $control = $sc.ControlTarget.Replace('\', '/') - $command = @($cmdExe, "/c", "echo ok 1> $control & echo denied 1> $denied") + $command = @($cmdExe, "/c", "echo denied 1> $denied & echo ok 1> $control") } else { $command = @($cmdExe, "/c", "echo denied 1> $denied") } @@ -547,7 +593,8 @@ try { Render-Toml # Preserve the exact rendered config + policy fixture used for this scenario. Copy-Item $toml (Join-Path $resultDir "mxc-gateway.$($sc.Name).toml") -Force -ErrorAction SilentlyContinue - Copy-Item $sc.PolicyFile (Join-Path $resultDir "policy.$($sc.Name).yaml") -Force -ErrorAction SilentlyContinue + # policyUsed already lives in the result bundle and is the exact + # rendered policy passed to OpenShell. $gw = Start-Gw Info "gateway pid $($gw.Id)" @@ -557,13 +604,12 @@ try { $sandboxName = "mxc-$($sc.SandboxId)-$runId" try { Invoke-NativeCaptured $cli @("sandbox", "delete", $sandboxName) | Out-Null } catch {} - # Run sandbox create. Its exit status is only authoritative for the - # create-fail scenario; artifacts score workload scenarios. + # Run sandbox create; require artifact evidence for live workload tests. $createOut = $null; $createExitCode = 0 try { $createResult = Invoke-NativeCaptured $cli @( "sandbox", "create", "--name", $sandboxName, - "--policy", [string]$sc.PolicyFile, + "--policy", [string]$policyUsed, "--driver-config-json", $driverConfig, "--no-tty" ) @@ -573,40 +619,14 @@ try { $createOut = $_.Exception.Message; $createExitCode = 1 } $createOutStr = ($createOut -join "`n") - Info "create exit: $createExitCode (not used for scoring on non-create-fail scenarios)" + Info "create exit: $createExitCode" $gwText = (Get-Content $gwLog, $gwErrLog -Raw -ErrorAction SilentlyContinue) -join "`n" # Evaluate. - if ($sc.Kind -eq "create-fail") { - # A non-zero exit alone is NOT sufficient: gateway-registration, - # transport, or malformed-fixture errors also exit non-zero and would - # false-pass this scenario. Require a genuine policy-rejection signal - # (the driver rejects the network rule with invalid_argument naming - # network_policies) AND confirm it is not an infrastructure failure. - $rejected = ($createOutStr -match '(?i)network' ` - -or $createOutStr -match '(?i)invalid[_ -]?argument' ` - -or $createOutStr -match '(?i)policy' ` - -or $gwText -match '(?i)network_policies') - $infraFail = ($createOutStr -match '(?i)connection refused' ` - -or $createOutStr -match '(?i)not registered' ` - -or $createOutStr -match '(?i)transport error' ` - -or $createOutStr -match '(?i)failed to connect') - if ($createExitCode -ne 0 -and $rejected -and -not $infraFail) { - Ok "$($sc.Name): create correctly rejected by policy (exit $createExitCode)" - $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "policy rejection" } - } elseif ($createExitCode -ne 0) { - Bad "$($sc.Name): create failed but not with a policy-rejection signal (possible harness/infra error)" - Info "output: $createOutStr" - $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "non-rejection failure" } - } else { - Bad "$($sc.Name): create succeeded but should have failed" - Info "output: $createOutStr" - $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "create succeeded unexpectedly" } - } - } elseif ($sc.Kind -eq "positive") { + if ($sc.Kind -eq "positive") { $present = Wait-File $sc.PosTarget 30 - if ($present) { + if ($present -and $createExitCode -eq 0) { Ok "$($sc.Name): in-policy write produced artifact" $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "artifact present" } } else { @@ -692,7 +712,7 @@ finally { $skipCount = @($results | Where-Object { $_.Result -eq "SKIP" }).Count Write-Host "PASS=$passCount FAIL=$failCount SKIP=$skipCount" - $verdict = if ($harnessError -or $failCount -gt 0) { "FAIL" } else { "PASS" } + $verdict = if ($harnessError -or $failCount -gt 0) { "FAIL" } elseif ($passCount -eq 0 -or $skipCount -gt 0) { "INCOMPLETE" } else { "PASS" } $tableText = ($results | Format-Table -AutoSize | Out-String) $summary = @" OpenShell MXC e2e scenario run @@ -700,7 +720,7 @@ OpenShell MXC e2e scenario run timestamp : $stamp machine : $env:COMPUTERNAME verdict : $verdict -mode : $(if ($Mock) { 'MOCK' } else { 'REAL' }) +mode : REAL backend : $Backend backend_live : $($backendProbe.Live) ($($backendProbe.Reason)) wxc_exec : $WxcExecPath @@ -717,9 +737,10 @@ Files in this bundle ($resultDir): mxc-gateway..toml the exact gateway config rendered per scenario policy..yaml the exact sandbox policy fixture used per scenario -What PASS means: every non-skipped scenario met its expected verdict - positive +What PASS means: every selected scenario ran against real MXC and met its expected verdict - positive writes produced their artifact, deny writes were blocked with either a control -write or driver-launch evidence, and network-reject was refused by policy. +write or driver-launch evidence. Missing coverage exits non-zero; +network-policy proves admission and execution, not network enforcement. "@ Set-Content -Path (Join-Path $resultDir "summary.txt") -Value $summary -Encoding UTF8 Write-Host $summary -ForegroundColor ($(if ($verdict -eq "PASS") { "Green" } else { "Red" })) @@ -728,17 +749,27 @@ write or driver-launch evidence, and network-reject was refused by policy. # Zip the bundle for easy return (defensive; never throw out of finally). try { - $zip = Join-Path $here "results-e2e-$stamp.zip" + $zip = Join-Path $resultsRoot "results-e2e-$stamp.zip" if (Test-Path $zip) { Remove-Item $zip -Force } Compress-Archive -Path (Join-Path $resultDir "*") -DestinationPath $zip -Force Write-Host "`nResults bundle: $zip" -ForegroundColor Yellow } catch { Write-Host "zip failed: $($_.Exception.Message)" -ForegroundColor Red } + if (-not ($KeepRunning -and $gw)) { + foreach ($keyFile in @('signing.pem', 'public.pem', 'kid')) { + Remove-Item -LiteralPath (Join-Path $secretDir $keyFile) -Force -ErrorAction SilentlyContinue + } + if (Test-Path $secretDir) { Remove-Item -LiteralPath $secretDir -ErrorAction SilentlyContinue } + } + foreach ($name in $savedEnv.Keys) { [Environment]::SetEnvironmentVariable($name, $savedEnv[$name], "Process") } } if ($harnessError -or $failCount -gt 0) { Write-Host "`nSOME SCENARIOS FAILED" -ForegroundColor Red exit 1 +} elseif ($passCount -eq 0 -or $skipCount -gt 0) { + Write-Host "`nE2E INCOMPLETE: PASS=$passCount SKIP=$skipCount - real runtime coverage required" -ForegroundColor Yellow + exit 1 } else { - Write-Host "`nALL SCENARIOS PASSED (or SKIPPED)" -ForegroundColor Green + Write-Host "`nALL SELECTED REAL MXC SCENARIOS PASSED: PASS=$passCount" -ForegroundColor Green exit 0 } diff --git a/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 index 591c38bf24..374ee2c042 100644 --- a/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 +++ b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 @@ -23,9 +23,6 @@ # powershell -NoProfile -ExecutionPolicy Bypass -File .\run-ocsf-audit.ps1 ` # -WxcExecPath C:\mxc-kit\bin\wxc-exec.exe # -# By default the per-sandbox egress proxy is ON so the full event set (including -# SandboxProxyConfigured) is produced. Pass -NoProxy to omit only that one event. -# # The deliverable is the OCSF audit log (openshell-ocsf..log) inside the # results-*.zip the script produces. Pass -ShareOut '\\server\share' to also copy # the bundle to a shared location (off by default). @@ -38,8 +35,6 @@ param( [string] $ShareDir = "C:\work\openshell-mxc-demo", # How many sandboxes to create (each drives a full event burst). [int] $SandboxCount = 2, - # Disable the per-sandbox egress proxy (omits the SandboxProxyConfigured event). - [switch] $NoProxy, # Gateway bind port (matches the gateway default) + CLI registration name. [int] $Port = 17670, [string] $GatewayName = "openshell-mxc-ocsf", @@ -86,6 +81,8 @@ function Get-MxcEtwSessions { $gateway = Join-Path $here "openshell-gateway.exe" $cli = Join-Path $here "openshell.exe" +$supervisor = Join-Path $here "openshell-supervisor.exe" +$sandbox = Join-Path $here "openshell-windows-sandbox.exe" $policySrc = Join-Path $here "ocsf-audit.yaml" $policy = Join-Path $resultDir "ocsf-audit.used.yaml" # disposable policy matching -ShareDir $tomlSrc = Join-Path $here "mxc-ocsf-audit.toml" @@ -95,12 +92,11 @@ $helloPath = Join-Path $ShareDir "hello.txt" $gw = $null $gatewayEtwSessions = @() $passed = $true -$proxyOn = -not $NoProxy try { # 1. Validate artifacts + privilege. Step "Validate package artifacts" - foreach ($f in @($gateway, $cli, $policySrc, $tomlSrc)) { + foreach ($f in @($gateway, $cli, $supervisor, $sandbox, $policySrc, $tomlSrc)) { if (-not (Test-Path $f)) { throw "missing artifact: $f (run this script from inside the package folder)" } Info "found $(Split-Path $f -Leaf)" } @@ -133,12 +129,6 @@ try { } else { $tomlText = [regex]::Replace($tomlText, '(?m)^\[openshell\.drivers\.mxc\]\s*$', "[openshell.drivers.mxc]`r`netw_audit = true") } - $proxyVal = if ($proxyOn) { 'true' } else { 'false' } - if ($tomlText -match '(?m)^\s*#?\s*egress_proxy\s*=') { - $tomlText = [regex]::Replace($tomlText, '(?m)^\s*#?\s*egress_proxy\s*=.*$', "egress_proxy = $proxyVal") - } else { - $tomlText = [regex]::Replace($tomlText, '(?m)^\[openshell\.drivers\.mxc\]\s*$', "[openshell.drivers.mxc]`r`negress_proxy = $proxyVal") - } Set-Content $toml -Value $tomlText -Encoding UTF8 $shareDirPolicy = $ShareDir.Replace('\', '/') @@ -168,7 +158,7 @@ try { $driverConfig } - Info "backend=process_container etw_audit=true egress_proxy=$proxyVal" + Info "backend=process_container etw_audit=true runtime=supervisor+sandbox" Info "workload cwd=$shareDirPolicy policy grant=$shareDirPolicy" # 3. Port must be free. Auto-clear a stale OUR-gateway; refuse anything else. diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 19af1ffcc3..4ec9613eb5 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -1,11 +1,17 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! MXC compute backend: lifecycle logic, in-memory registry, exec-in-driver, -//! and self-reported readiness. +//! MXC compute backend using the RFC 0012 supervisor/sandbox architecture. -use crate::mxc::{MxcFilesystem, MxcNetwork, MxcProcess, MxcProcessContainer, WxcExecInvoker}; -use crate::policy::{EmbeddedPolicyMapper, MapCtx, MappedConfig, PolicyMapper}; +use std::collections::HashMap; +use std::io; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::sync::Arc; +use std::time::Duration; + +use base64::Engine as _; use futures::Stream; use openshell_core::gpu::{driver_gpu_requirements, effective_driver_gpu_count}; use openshell_core::proto::SandboxPolicy; @@ -15,32 +21,31 @@ use openshell_core::proto::compute::v1::{ WatchSandboxesPlatformEvent, WatchSandboxesSandboxEvent, watch_sandboxes_event, }; use openshell_core::proto_struct::struct_to_json_value; +use openshell_sandbox_backend::boundary_protocol::{ + GatewayVerificationKey, SandboxTlsClientConfig, SandboxTlsServerConfig, + generate_sandbox_tls_material, +}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::net::SocketAddr; -use std::path::PathBuf; -use std::pin::Pin; -use std::sync::Arc; -use tokio::sync::{Mutex, broadcast, mpsc, watch}; -use tokio::task::JoinHandle; +use tokio::io::{AsyncBufReadExt as _, BufReader}; +use tokio::process::{Child, Command}; +use tokio::sync::{Mutex, broadcast, mpsc, oneshot, watch}; use tokio_stream::wrappers::ReceiverStream; use tracing::{info, warn}; +use crate::isolation::MxcBoundarySpec; +use crate::mxc::{MxcFilesystem, MxcNetwork, MxcProcess, MxcProcessContainer, WxcExecInvoker}; +use crate::policy::{EmbeddedPolicyMapper, MapCtx, MappedConfig, PolicyMapper}; + const DRIVER_NAME: &str = "mxc"; const DRIVER_VERSION: &str = env!("CARGO_PKG_VERSION"); -/// Sentinel image name — MXC has no OCI image; this string must be non-empty -/// so the gateway's `default_image` cache is satisfied, but it is not pullable. const DEFAULT_IMAGE_SENTINEL: &str = "mxc:process-container"; +const HOST_AUTH_BUNDLE_FILE: &str = "supervisor-auth.json"; +const HOST_RUNTIME_DESCRIPTOR_FILE: &str = "runtime-descriptor.json"; +const BOUNDARY_CONFIG_FILE: &str = "boundary.json"; +const BOUNDARY_TLS_CERT_FILE: &str = "sandbox.crt"; +const BOUNDARY_TLS_KEY_FILE: &str = "sandbox.key"; +const DIRECT_PROXY_USERNAME: &str = "openshell"; -// ── Config ──────────────────────────────────────────────────────────────────── - -/// Which MXC backend the driver targets. -/// -/// - `IsolationSession`: persistent, attachable session -/// (provision → start → exec → stop → deprovision). Grant-only filesystem -/// policy — it has no deny primitive and is NOT default-deny. -/// - `ProcessContainer` (default): one-shot `AppContainer`. Genuinely default-deny: a -/// write to any ungranted path is denied by the OS. No persistent session. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum MxcBackend { @@ -49,15 +54,18 @@ pub enum MxcBackend { ProcessContainer, } -/// Configuration for the MXC compute driver. -/// -/// Loaded from `[openshell.drivers.mxc]` in the gateway TOML file, or from -/// environment variables / CLI flags via the standard gateway precedence chain. +impl MxcBackend { + const fn containment(self) -> &'static str { + match self { + Self::IsolationSession => "isolation_session", + Self::ProcessContainer => "processcontainer", + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] -// These are independent, user-facing feature switches in the flat gateway -// configuration schema rather than one compound state machine. -#[allow(clippy::struct_excessive_bools)] +#[allow(clippy::struct_excessive_bools)] // independent operator options in the existing TOML schema pub struct MxcComputeConfig { /// Permit caller-supplied driver JSON. Does not waive resource admission. pub allow_driver_config: bool, @@ -65,54 +73,81 @@ pub struct MxcComputeConfig { pub resource_admission: openshell_core::resource_admission::ResourceAdmissionConfig, /// Path to `wxc-exec.exe`. Required for live runs. pub wxc_exec_path: String, - /// Backend to target. Default: `process_container`. + pub supervisor_binary_path: String, + pub sandbox_binary_path: String, + pub state_dir: PathBuf, + pub grpc_endpoint: String, pub backend: MxcBackend, - /// `processContainer` only: request a Less-Privileged `AppContainer`. + /// Legacy isolation-session setting, accepted for existing + /// `ProcessContainer` configurations but unused by that backend. + #[serde(skip_serializing)] + pub default_configuration_id: Option, pub pc_least_privilege: bool, - /// `processContainer` only: `AppContainer` capabilities to grant. pub pc_capabilities: Vec, - /// MXC `configurationId` for isolation session. Default: `"composable"`. - /// Never use `"small"` (known OS bug). - pub default_configuration_id: String, - /// Enable Pattern-C governed egress. When true, MXC receives filesystem - /// grants plus a `network.proxy` redirect and the host CONNECT proxy - /// receives the trimmed network-only policy. - pub egress_proxy: bool, - /// Loopback `IP:PORT` seed for MXC `network.proxy` while governed egress is - /// enabled. The driver preserves the loopback IP and allocates a unique - /// ephemeral port per sandbox. - pub egress_proxy_addr: String, - - /// Enable `--debug` flag on `wxc-exec` invocations. + pub pc_allow_local_network: bool, + pub pc_minimal_env: bool, pub debug: bool, - /// Enable the in-process ETW → OCSF audit consumer (Plane A). Consumes the OS - /// Sandboxing provider MXC drives and emits OCSF into the gateway trail. - /// Requires the gateway account to be in "Performance Log Users" (or admin). pub etw_audit: bool, } impl Default for MxcComputeConfig { fn default() -> Self { + let state_dir = std::env::var_os("LOCALAPPDATA") + .map_or_else(std::env::temp_dir, PathBuf::from) + .join("OpenShell") + .join("mxc"); + let executable_dir = std::env::current_exe() + .ok() + .and_then(|path| path.parent().map(Path::to_path_buf)); + let sibling_binary = |name: &str| { + executable_dir.as_ref().map_or_else( + || name.to_string(), + |dir| dir.join(name).display().to_string(), + ) + }; Self { wxc_exec_path: "wxc-exec.exe".into(), allow_driver_config: false, resource_admission: openshell_core::resource_admission::ResourceAdmissionConfig::default(), - backend: MxcBackend::default(), + supervisor_binary_path: sibling_binary("openshell-supervisor.exe"), + sandbox_binary_path: sibling_binary("openshell-windows-sandbox.exe"), + state_dir, + grpc_endpoint: String::new(), + backend: MxcBackend::ProcessContainer, + default_configuration_id: None, pc_least_privilege: false, pc_capabilities: Vec::new(), - default_configuration_id: crate::mxc::DEFAULT_CONFIGURATION_ID.into(), - egress_proxy: false, - egress_proxy_addr: String::new(), - + pc_allow_local_network: true, + pc_minimal_env: false, debug: false, etw_audit: false, } } } -/// Per-sandbox MXC workload settings supplied through -/// `template.driver_config.mxc` / `--driver-config-json`. +impl MxcComputeConfig { + /// Validate static MXC configuration without requiring launch credentials. + /// + /// Keep executable lookup independent of the gateway's PATH, as in the + /// Windows driver lane. Runtime file availability is checked at launch. + pub fn validate_configuration(&self) -> openshell_core::Result<()> { + if self.wxc_exec_path.trim().is_empty() || !Path::new(&self.wxc_exec_path).is_absolute() { + return Err(openshell_core::Error::config( + "[openshell.drivers.mxc] wxc_exec_path must be set to an absolute path to wxc-exec.exe", + )); + } + Ok(()) + } +} + +#[derive(Debug, Clone)] +struct GatewayConnection { + endpoint: String, + tls: Option, + tls_server_name: Option, +} + #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] struct MxcSandboxConfig { @@ -121,8 +156,6 @@ struct MxcSandboxConfig { cwd: String, } -// ── Registry entry ──────────────────────────────────────────────────────────── - #[derive(Debug, Clone, PartialEq, Eq)] pub enum PhaseState { Starting, @@ -133,333 +166,105 @@ pub enum PhaseState { struct SandboxEntry { sandbox: DriverSandbox, - iso_sandbox_id: Option, - isolation_stopped: bool, phase_state: PhaseState, - /// Serializes stop/delete with provisioning and process launch. lifecycle_gate: Arc>, - monitor_cancel: Option>, - monitor_task: Option>, - trimmed_policy: Option, - proxy_addr: Option, - host_proxy: Option, -} - -impl std::fmt::Debug for SandboxEntry { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("SandboxEntry") - .field("sandbox_id", &self.sandbox.id) - .field("iso_sandbox_id", &self.iso_sandbox_id) - .field("isolation_stopped", &self.isolation_stopped) - .field("phase_state", &self.phase_state) - .finish_non_exhaustive() - } + shutdown_tx: Option>, + terminated_rx: Option>, + host_state_dir: PathBuf, + boundary_state_dir: PathBuf, } -// ── Watch stream helpers ────────────────────────────────────────────────────── - pub type WatchStream = Pin< Box> + Send>, >; -fn sandbox_event(sandbox: DriverSandbox) -> WatchSandboxesEvent { - WatchSandboxesEvent { - payload: Some(watch_sandboxes_event::Payload::Sandbox( - WatchSandboxesSandboxEvent { - sandbox: Some(sandbox), - }, - )), - } -} - -fn deleted_event(sandbox_id: String) -> WatchSandboxesEvent { - WatchSandboxesEvent { - payload: Some(watch_sandboxes_event::Payload::Deleted( - WatchSandboxesDeletedEvent { sandbox_id }, - )), - } -} - -fn platform_event(sandbox_id: String, reason: &str, message: String) -> WatchSandboxesEvent { - WatchSandboxesEvent { - payload: Some(watch_sandboxes_event::Payload::PlatformEvent( - WatchSandboxesPlatformEvent { - sandbox_id, - event: Some(DriverPlatformEvent { - event_time: None, - source: "mxc-driver".into(), - r#type: "Warning".into(), - reason: reason.to_string(), - message, - metadata: HashMap::new(), - }), - }, - )), - } -} - -// ── Driver ──────────────────────────────────────────────────────────────────── - -/// In-process MXC compute driver. pub struct MxcComputeBackend { config: MxcComputeConfig, + gateway: GatewayConnection, invoker: WxcExecInvoker, registry: Arc>>, watch_tx: Arc>, policy_mapper: Arc, - /// In-process ETW → OCSF audit consumer (Plane A). `Some` only when - /// `config.etw_audit` is set and the session started; kept alive here so it - /// stops when the backend is dropped (held purely for its `Drop`, hence - /// never read directly). #[allow(dead_code)] etw_session: Option, - /// Shared MXC-ETW → `sandbox_id` attribution index. Seeded by the driver - /// (`pid → sandbox_id`) as it launches sandboxes and read by the ETW - /// consumer thread to map/emit OCSF. `Arc` even when audit is off so the - /// launch path is branch-free. attribution: Arc>, } impl std::fmt::Debug for MxcComputeBackend { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("MxcComputeBackend") + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("MxcComputeBackend") .field("wxc_exec_path", &self.config.wxc_exec_path) - .finish_non_exhaustive() - } -} - -fn sandbox_config(sandbox: &DriverSandbox) -> Result { - let config = sandbox - .spec - .as_ref() - .and_then(|spec| spec.template.as_ref()) - .and_then(|template| template.driver_config.as_ref()) - .ok_or_else(|| { - tonic::Status::invalid_argument( - "mxc requires template.driver_config.mxc with a non-empty command array", + .field( + "supervisor_binary_path", + &self.config.supervisor_binary_path, ) - })?; - let config: MxcSandboxConfig = - serde_json::from_value(struct_to_json_value(config)).map_err(|error| { - tonic::Status::invalid_argument(format!("invalid mxc driver_config: {error}")) - })?; - if config.command.is_empty() || config.command[0].is_empty() { - return Err(tonic::Status::invalid_argument( - "mxc driver_config.command must contain a non-empty executable", - )); - } - Ok(config) -} - -// Minimum non-secret Windows environment needed by CreateProcessW and the -// AppContainer DACL fallback before the workload runtime starts. -const MINIMAL_WINDOWS_BOOTSTRAP_ENV: [&str; 5] = - ["SYSTEMROOT", "WINDIR", "PATH", "COMSPEC", "LOCALAPPDATA"]; - -fn sandbox_environment(sandbox: &DriverSandbox) -> Vec { - // Released wxc-exec ProcessContainer builds start from the explicit - // process environment. Seed only the non-secret Windows bootstrap values; - // copying the gateway's full environment would leak unrelated host secrets - // into untrusted sandbox workloads. - let mut environment = MINIMAL_WINDOWS_BOOTSTRAP_ENV - .iter() - .filter_map(|key| { - std::env::var(key) - .ok() - .map(|value| ((*key).to_string(), value)) - }) - .collect::>(); - if let Some(spec) = sandbox.spec.as_ref() { - if let Some(template) = spec.template.as_ref() { - environment.extend(template.environment.clone()); - } - environment.extend(spec.environment.clone()); - } - let mut environment = environment - .into_iter() - .map(|(key, value)| format!("{key}={value}")) - .collect::>(); - environment.sort_unstable(); - environment -} - -fn configured_egress_addr(config: &MxcComputeConfig) -> Result, tonic::Status> { - if !config.egress_proxy { - return Ok(None); - } - if config.backend == MxcBackend::IsolationSession { - return Err(tonic::Status::invalid_argument( - "mxc governed egress requires process_container; network.proxy is not supported on isolation_session until MXC M1 lands", - )); - } - let raw = config.egress_proxy_addr.trim(); - if raw.is_empty() { - return Err(tonic::Status::invalid_argument( - "mxc egress_proxy_addr is required when egress_proxy is enabled", - )); - } - let addr = raw.parse::().map_err(|error| { - tonic::Status::invalid_argument(format!( - "mxc egress_proxy_addr must be an IP:PORT socket address: {error}" - )) - })?; - if addr.ip() != std::net::IpAddr::from([127, 0, 0, 1]) { - return Err(tonic::Status::invalid_argument(format!( - "mxc egress_proxy_addr must be 127.0.0.1:PORT because MXC 0.6.0-alpha can encode only a localhost proxy port (got {})", - addr.ip() - ))); + .field("sandbox_binary_path", &self.config.sandbox_binary_path) + .finish_non_exhaustive() } - Ok(Some(addr)) -} - -fn allocate_sandbox_proxy_addr( - configured: SocketAddr, -) -> std::io::Result<(SocketAddr, std::net::TcpListener)> { - let reservation = std::net::TcpListener::bind(SocketAddr::new(configured.ip(), 0))?; - let addr = reservation.local_addr()?; - Ok((addr, reservation)) -} - -fn encode_windows_command_line(args: &[String]) -> String { - args.iter() - .map(|arg| quote_windows_argument(arg)) - .collect::>() - .join(" ") } -fn quote_windows_argument(arg: &str) -> String { - if !arg.is_empty() && !arg.chars().any(|ch| ch.is_whitespace() || ch == '"') { - return arg.to_string(); - } - - let mut quoted = String::from("\""); - let mut backslashes = 0; - for ch in arg.chars() { - match ch { - '\\' => backslashes += 1, - '"' => { - quoted.push_str(&"\\".repeat(backslashes * 2 + 1)); - quoted.push('"'); - backslashes = 0; - } - _ => { - quoted.push_str(&"\\".repeat(backslashes)); - backslashes = 0; - quoted.push(ch); - } +impl MxcComputeBackend { + /// Compose the driver's host connection from generic gateway inputs. + /// MXC endpoint defaults and TLS server-name selection stay in the driver. + pub fn for_gateway( + gateway_name: &str, + mut config: MxcComputeConfig, + gateway_port: u16, + gateway_tls_enabled: bool, + tls_ca: Option, + ) -> Self { + let use_internal_endpoint = config.grpc_endpoint.trim().is_empty(); + if use_internal_endpoint { + let scheme = if gateway_tls_enabled { "https" } else { "http" }; + config.grpc_endpoint = format!("{scheme}://127.0.0.1:{gateway_port}"); } + let endpoint = config.grpc_endpoint.clone(); + let tls_server_name = + (use_internal_endpoint && gateway_tls_enabled).then(|| "localhost".to_string()); + Self::new_with_gateway(gateway_name, config, endpoint, tls_ca, tls_server_name) } - quoted.push_str(&"\\".repeat(backslashes * 2)); - quoted.push('"'); - quoted -} -fn host_proxy_binary_path(config: &MxcSandboxConfig) -> PathBuf { - config - .command - .first() - .filter(|command| !command.trim().is_empty()) - .map_or_else(|| PathBuf::from("mxc-agent"), PathBuf::from) -} - -const TLS_ENV_KEYS: [&str; 6] = [ - "NODE_EXTRA_CA_CERTS", - "DENO_CERT", - "SSL_CERT_FILE", - "REQUESTS_CA_BUNDLE", - "CURL_CA_BUNDLE", - "GIT_SSL_CAINFO", -]; - -fn append_tls_env_vars(env: &mut Vec, ca_paths: Option<&(PathBuf, PathBuf)>) { - let Some((ca_cert_path, combined_bundle_path)) = ca_paths else { - return; - }; - - env.retain(|entry| { - let key = entry.split_once('=').map_or(entry.as_str(), |(key, _)| key); - !TLS_ENV_KEYS - .iter() - .any(|candidate| key.eq_ignore_ascii_case(candidate)) - }); - - let ca_cert_path = ca_cert_path.display().to_string(); - let combined_bundle_path = combined_bundle_path.display().to_string(); - env.extend([ - format!("NODE_EXTRA_CA_CERTS={ca_cert_path}"), - format!("DENO_CERT={ca_cert_path}"), - format!("SSL_CERT_FILE={combined_bundle_path}"), - format!("REQUESTS_CA_BUNDLE={combined_bundle_path}"), - format!("CURL_CA_BUNDLE={combined_bundle_path}"), - format!("GIT_SSL_CAINFO={combined_bundle_path}"), - ]); -} -fn append_tls_readwrite_grant( - readwrite_paths: &mut Vec, - ca_paths: Option<&(PathBuf, PathBuf)>, -) { - let Some((ca_cert_path, _)) = ca_paths else { - return; - }; - let Some(dir) = ca_cert_path.parent() else { - return; - }; - let dir = dir.display().to_string(); - if !readwrite_paths - .iter() - .any(|existing| existing.eq_ignore_ascii_case(&dir)) - { - readwrite_paths.push(dir); + pub fn new(gateway_name: &str, config: MxcComputeConfig) -> Self { + let endpoint = config.grpc_endpoint.clone(); + Self::new_with_gateway(gateway_name, config, endpoint, None, None) } -} -impl MxcComputeBackend { - /// Create an MXC backend whose audit records identify the configured gateway. - pub fn new(gateway_name: impl Into, config: MxcComputeConfig) -> Self { + pub fn new_with_gateway( + gateway_name: &str, + config: MxcComputeConfig, + endpoint: String, + tls: Option, + tls_server_name: Option, + ) -> Self { let invoker = WxcExecInvoker::new(&config.wxc_exec_path, config.debug); let (watch_tx, _) = broadcast::channel(256); - let gateway_name = gateway_name.into(); - - // Start the Plane-A ETW → OCSF consumer if enabled. The consumer thread - // attributes each event to a `sandbox_id` via `attribution` (seeded by - // the launch path) and emits OCSF for the mapped classes. - // Failure is non-fatal — the driver still runs, just without ETW audit. let attribution = Arc::new(std::sync::Mutex::new( crate::etw_consumer::AttributionIndex::new(), )); let etw_session = if config.etw_audit { - match crate::etw_consumer::start_session(attribution.clone(), gateway_name) { - Ok(session) => Some(session), - Err(e) => { - warn!(error = %e, "MXC ETW audit consumer failed to start; continuing without it"); - None - } - } + crate::etw_consumer::start_session(attribution.clone(), gateway_name.to_string()) + .inspect_err(|error| warn!(%error, "MXC ETW audit consumer failed to start")) + .ok() } else { None }; - Self { - invoker, config, + gateway: GatewayConnection { + endpoint, + tls, + tls_server_name, + }, + invoker, registry: Arc::new(Mutex::new(HashMap::new())), watch_tx: Arc::new(watch_tx), - // Production policy translation is always handled by the embedded - // mapper before any MXC lifecycle side effects begin. policy_mapper: Arc::new(EmbeddedPolicyMapper), etw_session, attribution, } } - /// Test-only constructor wiring the in-process mock `wxc-exec` shim. - #[cfg(test)] - pub(crate) fn new_mocked(config: MxcComputeConfig) -> Self { - let mut backend = Self::new("test", config); - backend.invoker = WxcExecInvoker::mocked(&backend.config.wxc_exec_path); - backend - } - pub fn capabilities(&self) -> GetCapabilitiesResponse { GetCapabilitiesResponse { resource_admission_policy: openshell_core::resource_admission::DriverAdmissionConfig { @@ -467,45 +272,95 @@ impl MxcComputeBackend { resource_admission: self.config.resource_admission.clone(), } .acknowledgement(), + extension: Some(openshell_core::extension_protocol::extension_metadata( + openshell_core::extension_protocol::ExtensionFamily::Compute, + "openshell/mxc", + openshell_core::VERSION, + [], + )), driver_name: DRIVER_NAME.to_string(), driver_version: DRIVER_VERSION.to_string(), default_image: DEFAULT_IMAGE_SENTINEL.to_string(), gateway_manages_lifecycle: false, supports_sandbox_authentication: false, - driver_reports_runtime_readiness: true, + driver_reports_runtime_readiness: false, resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, - extension: Some(openshell_core::extension_protocol::extension_metadata( - openshell_core::extension_protocol::ExtensionFamily::Compute, - "openshell/mxc", - openshell_core::VERSION, - [], - )), + supports_ui_policy: true, } } - fn validate_sandbox_fields(sandbox: &DriverSandbox) -> Result<(), tonic::Status> { - if let Some(spec) = &sandbox.spec { - if effective_driver_gpu_count(driver_gpu_requirements( + pub fn validate_sandbox_create(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { + self.config + .resource_admission + .validate() + .map_err(tonic::Status::failed_precondition)?; + openshell_core::resource_admission::check_sandbox_driver_config( + self.config.allow_driver_config, + sandbox, + )?; + // Host filesystem grants still have no authoritative label resolver. + self.config + .resource_admission + .reject_unlabelable("MXC host filesystem grants")?; + if !self.invoker.is_mock() { + self.config + .validate_configuration() + .map_err(|error| tonic::Status::failed_precondition(error.to_string()))?; + } + if self.config.backend != MxcBackend::ProcessContainer { + return Err(tonic::Status::failed_precondition( + "the RFC 0012 MXC runtime currently requires process_container", + )); + } + if self.gateway.endpoint.trim().is_empty() { + return Err(tonic::Status::failed_precondition( + "mxc grpc_endpoint is required for the host supervisor", + )); + } + if !self.invoker.is_mock() + && (!Path::new(&self.config.supervisor_binary_path).is_file() + || !Path::new(&self.config.sandbox_binary_path).is_file()) + { + return Err(tonic::Status::failed_precondition(format!( + "MXC requires supervisor and sandbox binaries at '{}' and '{}'", + self.config.supervisor_binary_path, self.config.sandbox_binary_path + ))); + } + if let Some(spec) = &sandbox.spec + && effective_driver_gpu_count(driver_gpu_requirements( spec.resource_requirements.as_ref(), )) .map_err(tonic::Status::invalid_argument)? .is_some() - { - return Err(tonic::Status::invalid_argument( - "mxc driver does not support GPU sandboxes", - )); - } - if let Some(tmpl) = &spec.template - && !tmpl.agent_socket_path.is_empty() - { - return Err(tonic::Status::invalid_argument( - "mxc driver does not support agent_socket_path (no in-sandbox supervisor)", - )); - } + { + return Err(tonic::Status::invalid_argument( + "mxc driver does not support GPU sandboxes", + )); + } + let config = sandbox_config(sandbox)?; + if config.cwd.trim().is_empty() { + return Err(tonic::Status::invalid_argument( + "mxc driver_config.cwd is required for boundary staging", + )); + } + if !Path::new(&config.cwd).is_absolute() { + return Err(tonic::Status::invalid_argument( + "mxc driver_config.cwd must be an absolute Windows path", + )); + } + if !self.config.state_dir.is_absolute() { + return Err(tonic::Status::failed_precondition( + "mxc state_dir must be an absolute Windows path", + )); } - sandbox_config(sandbox)?; + let policy = sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref()); + self.map_sandbox_policy( + &sandbox.id, + policy, + "127.0.0.1:3128".parse().expect("fixed proxy address"), + )?; Ok(()) } @@ -513,157 +368,109 @@ impl MxcComputeBackend { &self, sandbox_id: &str, policy: Option<&SandboxPolicy>, - egress: Option, + proxy_addr: SocketAddr, ) -> Result { self.policy_mapper .map( policy, &MapCtx { sandbox_id: sandbox_id.to_string(), - egress, + egress: Some(proxy_addr), + containment: self.config.backend.containment().into(), }, ) .map_err(|error| tonic::Status::invalid_argument(error.to_string())) } - pub fn validate_sandbox_create(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { - self.config - .resource_admission - .validate() - .map_err(tonic::Status::failed_precondition)?; - openshell_core::resource_admission::check_sandbox_driver_config( - self.config.allow_driver_config, - sandbox, - )?; - // MXC grants access to existing host filesystem objects, including its - // executable/workdir. There is no authoritative label resolver yet. - self.config - .resource_admission - .reject_unlabelable("MXC host filesystem grants")?; - Self::validate_sandbox_fields(sandbox)?; - let policy = sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref()); - let egress_addr = configured_egress_addr(&self.config)?; - self.map_sandbox_policy(&sandbox.id, policy, egress_addr)?; - Ok(()) - } pub async fn get_sandbox(&self, sandbox_name: &str) -> Option { - let registry = self.registry.lock().await; - registry + self.registry + .lock() + .await .values() - .find(|e| e.sandbox.name == sandbox_name) - .map(|e| e.sandbox.clone()) + .find(|entry| entry.sandbox.name == sandbox_name) + .map(|entry| entry.sandbox.clone()) } pub async fn list_sandboxes(&self) -> Vec { - let registry = self.registry.lock().await; - registry.values().map(|e| e.sandbox.clone()).collect() + self.registry + .lock() + .await + .values() + .map(|entry| entry.sandbox.clone()) + .collect() } pub async fn create_sandbox(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { self.validate_sandbox_create(sandbox)?; + // Gateway preflight runs before it mints generation-scoped credentials. + // Require authentication at the provisioning boundary, before any state + // directories, registry entries, or runtime processes are created. + let launch = launch_authentication(sandbox)?; + launch + .validate() + .map_err(|error| tonic::Status::failed_precondition(error.to_string()))?; let sandbox_id = sandbox.id.clone(); - - Self::validate_sandbox_fields(sandbox)?; let sandbox_config = sandbox_config(sandbox)?; - let (egress_addr, reserved_proxy_listener) = match configured_egress_addr(&self.config)? { - Some(configured_addr) => { - let (addr, reservation) = allocate_sandbox_proxy_addr(configured_addr).map_err( - |error| { - tonic::Status::internal(format!( - "failed to allocate sandbox-unique MXC host egress proxy address from {configured_addr}: {error}" - )) - }, - )?; - (Some(addr), Some(reservation)) - } - None => (None, None), - }; - - // Policy translation is deterministic and side-effect free. Do it before - // inserting the registry entry or launching MXC so invalid requests fail - // synchronously at the CreateSandbox boundary. - let policy = sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref()); - let mapped = self.map_sandbox_policy(&sandbox_id, policy, egress_addr)?; - - if sandbox - .spec - .as_ref() - .is_none_or(|spec| spec.sandbox_token.is_empty()) - { - tracing::debug!( - sandbox = %sandbox.name, - "no sandbox_token minted (no supervisor consumer on MXC)" - ); - } - - let sandbox_name = sandbox.name.clone(); - let lifecycle_gate = Arc::new(Mutex::new(())); - // Take the gate before publishing the entry. stop/delete can discover the - // sandbox immediately, but cannot pass this guard until startup has either - // installed a cancellable child monitor or failed. - let startup_guard = lifecycle_gate.clone().lock_owned().await; + // The gateway owns the authenticated generation. A driver-local UUID + // would make the boundary descriptor disagree with the signed bundle. + let generation = safe_component(launch.supervisor.runtime_generation.as_str())?.to_string(); + let host_state_dir = self + .config + .state_dir + .join(safe_component(&sandbox_id)?) + .join(&generation); + let boundary_state_dir = PathBuf::from(&sandbox_config.cwd) + .join(".openshell-runtime") + .join(&generation); + let gate = Arc::new(Mutex::new(())); + let startup_guard = gate.clone().lock_owned().await; + let starting = make_sandbox_with_condition( + sandbox, + &condition("Ready", "False", "Starting", "MXC runtime starting"), + false, + ); { let mut registry = self.registry.lock().await; if registry.contains_key(&sandbox_id) { return Err(tonic::Status::already_exists(format!( - "sandbox {sandbox_name} already exists" + "sandbox {} already exists", + sandbox.name ))); } - let initial = make_sandbox_with_condition( - sandbox, - &DriverCondition { - r#type: "Ready".into(), - status: "False".into(), - reason: "Starting".into(), - message: "MXC lifecycle starting".into(), - transition_time: None, - }, - false, - ); - let _ = self.watch_tx.send(sandbox_event(initial.clone())); registry.insert( sandbox_id.clone(), SandboxEntry { - sandbox: initial, - iso_sandbox_id: None, - isolation_stopped: false, + sandbox: starting.clone(), phase_state: PhaseState::Starting, - lifecycle_gate, - monitor_cancel: None, - monitor_task: None, - trimmed_policy: None, - proxy_addr: None, - host_proxy: None, + lifecycle_gate: gate, + shutdown_tx: None, + terminated_rx: None, + host_state_dir: host_state_dir.clone(), + boundary_state_dir: boundary_state_dir.clone(), }, ); } - - let invoker = self.invoker.clone(); - let config = self.config.clone(); - let registry = self.registry.clone(); - let watch_tx = self.watch_tx.clone(); - let attribution = self.attribution.clone(); - let sandbox = sandbox.clone(); - tokio::spawn(async move { - run_lifecycle( - invoker, - config, - registry, - watch_tx, - attribution, - sandbox, - sandbox_config, - mapped, - reserved_proxy_listener, - startup_guard, - ) - .await; - }); - + let _ = self.watch_tx.send(sandbox_event(starting)); + let context = LifecycleContext { + invoker: self.invoker.clone(), + config: self.config.clone(), + gateway: self.gateway.clone(), + registry: self.registry.clone(), + watch_tx: self.watch_tx.clone(), + attribution: self.attribution.clone(), + sandbox: sandbox.clone(), + sandbox_config, + generation, + host_state_dir, + boundary_state_dir, + policy_mapper: self.policy_mapper.clone(), + }; + tokio::spawn(async move { run_lifecycle(context, startup_guard).await }); Ok(()) } + pub async fn stop_sandbox(&self, sandbox_name: &str) -> Result<(), tonic::Status> { - let (sandbox_id, lifecycle_gate) = { + let (sandbox_id, gate) = { let registry = self.registry.lock().await; let entry = registry .values() @@ -673,51 +480,24 @@ impl MxcComputeBackend { })?; (entry.sandbox.id.clone(), entry.lifecycle_gate.clone()) }; - - let _lifecycle_guard = lifecycle_gate.lock().await; - let (iso_id, mut isolation_stopped, cancel, monitor_task) = { + let _guard = gate.lock().await; + let (shutdown, terminated) = { let mut registry = self.registry.lock().await; let entry = registry.get_mut(&sandbox_id).ok_or_else(|| { tonic::Status::not_found(format!("sandbox {sandbox_name} not found")) })?; - ( - entry.iso_sandbox_id.clone(), - entry.isolation_stopped, - entry.monitor_cancel.take(), - entry.monitor_task.take(), - ) + (entry.shutdown_tx.take(), entry.terminated_rx.clone()) }; - if let Some(cancel) = cancel { - let _ = cancel.send(true); - } - if let Some(task) = monitor_task { - task.await.map_err(|error| { - tonic::Status::internal(format!("mxc process monitor failed: {error}")) - })?; - } - if let Some(ref iso_id) = iso_id - && !isolation_stopped - { - self.invoker.stop(iso_id).await.map_err(|error| { - tonic::Status::internal(format!("wxc-exec stop failed: {error}")) - })?; - isolation_stopped = true; + if let Some(shutdown) = shutdown { + let _ = shutdown.send(()); } - + wait_for_termination(terminated, sandbox_name).await?; let mut registry = self.registry.lock().await; if let Some(entry) = registry.get_mut(&sandbox_id) { - entry.isolation_stopped = isolation_stopped; - entry.host_proxy = None; entry.phase_state = PhaseState::Stopped; entry.sandbox = make_sandbox_with_condition( &entry.sandbox, - &DriverCondition { - r#type: "Ready".into(), - status: "False".into(), - reason: "Stopped".into(), - message: "MXC sandbox stopped".into(), - transition_time: None, - }, + &condition("Ready", "False", "Stopped", "MXC sandbox stopped"), false, ); let snapshot = entry.sandbox.clone(); @@ -726,12 +506,13 @@ impl MxcComputeBackend { } Ok(()) } + pub async fn delete_sandbox( &self, sandbox_id: &str, sandbox_name: &str, ) -> Result { - let lifecycle_gate = { + let gate = { let registry = self.registry.lock().await; let Some(entry) = registry.get(sandbox_id) else { return Ok(false); @@ -743,482 +524,652 @@ impl MxcComputeBackend { } entry.lifecycle_gate.clone() }; - - let _lifecycle_guard = lifecycle_gate.lock().await; - let (iso_id, isolation_stopped, cancel, monitor_task) = { + let _guard = gate.lock().await; + let (shutdown, terminated, host_state, boundary_state) = { let mut registry = self.registry.lock().await; - let Some(entry) = registry.get_mut(sandbox_id) else { - return Ok(false); - }; + let entry = registry.get_mut(sandbox_id).expect("entry checked above"); ( - entry.iso_sandbox_id.clone(), - entry.isolation_stopped, - entry.monitor_cancel.take(), - entry.monitor_task.take(), + entry.shutdown_tx.take(), + entry.terminated_rx.clone(), + entry.host_state_dir.clone(), + entry.boundary_state_dir.clone(), ) }; - if let Some(cancel) = cancel { - let _ = cancel.send(true); + if let Some(shutdown) = shutdown { + let _ = shutdown.send(()); } - if let Some(task) = monitor_task { - task.await.map_err(|error| { - tonic::Status::internal(format!("mxc process monitor failed: {error}")) - })?; - } - if let Some(ref iso_id) = iso_id { - if !isolation_stopped { - self.invoker.stop(iso_id).await.map_err(|error| { - tonic::Status::internal(format!("wxc-exec stop failed: {error}")) - })?; - // Persist phase progress before deprovision. If deprovision - // fails, a retry resumes here instead of stopping twice. - let mut registry = self.registry.lock().await; - if let Some(entry) = registry.get_mut(sandbox_id) { - entry.isolation_stopped = true; - } - } - self.invoker.deprovision(iso_id).await.map_err(|error| { - tonic::Status::internal(format!("wxc-exec deprovision failed: {error}")) - })?; - } - - let mut registry = self.registry.lock().await; - if registry.remove(sandbox_id).is_some() { - if let Ok(mut idx) = self.attribution.lock() { - idx.forget(sandbox_id); + wait_for_termination(terminated, sandbox_name).await?; + cleanup_runtime_directory(&host_state); + cleanup_runtime_directory(&boundary_state); + let removed = self.registry.lock().await.remove(sandbox_id).is_some(); + if removed { + if let Ok(mut attribution) = self.attribution.lock() { + attribution.forget(sandbox_id); } let _ = self.watch_tx.send(deleted_event(sandbox_id.to_string())); - return Ok(true); } - Ok(false) + Ok(removed) } - /// Returns a stream of watch events. - /// - /// First emits a snapshot of all current sandboxes, then forwards live - /// events from the broadcast channel. - pub async fn watch_sandboxes(&self) -> WatchStream { - let (tx, rx) = - mpsc::channel::>(256); - // Subscribe while holding the registry lock. Every transition is then - // represented by either this snapshot or the live receiver. - let (snapshots, mut broadcast_rx): (Vec, _) = { + pub async fn watch_sandboxes(&self) -> WatchStream { + let (tx, rx) = mpsc::channel(256); + let (snapshots, mut updates) = { let registry = self.registry.lock().await; - let broadcast_rx = self.watch_tx.subscribe(); - let snapshots = registry - .values() - .map(|entry| entry.sandbox.clone()) - .collect(); - (snapshots, broadcast_rx) + ( + registry + .values() + .map(|entry| entry.sandbox.clone()) + .collect::>(), + self.watch_tx.subscribe(), + ) }; - - let tx_clone = tx.clone(); tokio::spawn(async move { - // Deliver initial snapshots. - for sb in snapshots { - if tx_clone.send(Ok(sandbox_event(sb))).await.is_err() { + for sandbox in snapshots { + if tx.send(Ok(sandbox_event(sandbox))).await.is_err() { return; } } - // Forward live events. loop { - match broadcast_rx.recv().await { + match updates.recv().await { Ok(event) => { - if tx_clone.send(Ok(event)).await.is_err() { - break; + if tx.send(Ok(event)).await.is_err() { + return; } } - Err(broadcast::error::RecvError::Lagged(_)) => { - // Drop lagged events — the gateway re-syncs via Get/List. - } - Err(broadcast::error::RecvError::Closed) => break, + Err(broadcast::error::RecvError::Lagged(_)) => {} + Err(broadcast::error::RecvError::Closed) => return, } } }); - Box::pin(ReceiverStream::new(rx)) } } -// ── Lifecycle task ──────────────────────────────────────────────────────────── - -#[allow(clippy::too_many_arguments)] -async fn run_lifecycle( +#[derive(Clone)] +struct LifecycleContext { invoker: WxcExecInvoker, config: MxcComputeConfig, + gateway: GatewayConnection, registry: Arc>>, watch_tx: Arc>, attribution: Arc>, sandbox: DriverSandbox, sandbox_config: MxcSandboxConfig, - mapped: MappedConfig, - mut reserved_proxy_listener: Option, - _startup_guard: tokio::sync::OwnedMutexGuard<()>, -) { - let sandbox_id = sandbox.id.clone(); - let sandbox_name = sandbox.name.clone(); - let trimmed_policy = mapped.trimmed_policy.clone(); - let proxy_addr = mapped.proxy_addr; - let host_proxy = if !invoker.is_mock() - && let (Some(addr), Some(proxy_policy)) = (proxy_addr, trimmed_policy.clone()) - { - drop(reserved_proxy_listener.take()); - match openshell_supervisor_network::host::start_host_proxy( - openshell_supervisor_network::host::HostProxyConfig { - bind_addr: addr, - policy: proxy_policy, - binary_path: host_proxy_binary_path(&sandbox_config), - sandbox_id: Some(sandbox_id.clone()), - sandbox_name: Some(sandbox_name.clone()), - openshell_endpoint: None, - provider_credentials: None, - agent_proposals: openshell_core::proposals::AgentProposals::default(), - denial_tx: None, - activity_tx: None, + generation: String, + host_state_dir: PathBuf, + boundary_state_dir: PathBuf, + policy_mapper: Arc, +} + +async fn run_lifecycle(context: LifecycleContext, startup_guard: tokio::sync::OwnedMutexGuard<()>) { + if let Err(error) = run_lifecycle_inner(&context, startup_guard).await { + set_failed(&context, &error).await; + } +} + +async fn run_lifecycle_inner( + context: &LifecycleContext, + startup_guard: tokio::sync::OwnedMutexGuard<()>, +) -> Result<(), String> { + create_restricted_state_dir(&context.host_state_dir, "host")?; + // This directory briefly contains the boundary TLS private key and direct + // proxy credential. Restrict host access before writing either secret; + // MXC adds the ProcessContainer grant when it applies the read-write path. + create_restricted_state_dir(&context.boundary_state_dir, "boundary")?; + let launch = launch_authentication(&context.sandbox).map_err(|error| error.to_string())?; + let (control_addr, control_reservation) = reserve_loopback_port() + .map_err(|error| format!("reserve MXC Sandbox Protocol port: {error}"))?; + let (proxy_addr, proxy_reservation) = reserve_loopback_port() + .map_err(|error| format!("reserve MXC supervisor proxy port: {error}"))?; + let mapped = context + .policy_mapper + .map( + context + .sandbox + .spec + .as_ref() + .and_then(|spec| spec.policy.as_ref()), + &MapCtx { + sandbox_id: context.sandbox.id.clone(), + egress: Some(proxy_addr), + containment: MxcBackend::ProcessContainer.containment().to_string(), }, ) - .await - { - Ok(handle) => Some(handle), - Err(error) => { - set_failed( - ®istry, - &watch_tx, - &sandbox, - &sandbox_id, - &format!("failed to start MXC host egress proxy at {addr}: {error}"), - ) - .await; - return; - } - } - } else { - None - }; - let host_proxy_ca_paths = host_proxy - .as_ref() - .and_then(openshell_supervisor_network::host::HostProxyHandle::ca_file_paths); - drop(reserved_proxy_listener.take()); - if let Some(addr) = proxy_addr { - { - let mut registry = registry.lock().await; - if let Some(entry) = registry.get_mut(&sandbox_id) { - entry.trimmed_policy = trimmed_policy; - entry.proxy_addr = Some(addr); - entry.host_proxy = host_proxy; - } - } - let _ = watch_tx.send(platform_event( - sandbox_id.clone(), - "EgressRedirect", - format!("MXC egress redirected to OpenShell host CONNECT proxy at {addr}"), - )); + .map_err(|error| error.to_string())?; + let tls = generate_sandbox_tls_material(launch.supervisor.session_id) + .map_err(|error| error.to_string())?; + let tls_cert_path = context.boundary_state_dir.join(BOUNDARY_TLS_CERT_FILE); + let tls_key_path = context.boundary_state_dir.join(BOUNDARY_TLS_KEY_FILE); + std::fs::write(&tls_cert_path, tls.certificate_chain_pem.as_bytes()) + .map_err(|error| format!("write MXC boundary TLS certificate: {error}"))?; + std::fs::write(&tls_key_path, tls.private_key_pem.as_bytes()) + .map_err(|error| format!("write MXC boundary TLS private key: {error}"))?; + let proxy_password = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(rand::random::<[u8; 32]>()); + let authorization = format!( + "Basic {}", + base64::engine::general_purpose::STANDARD + .encode(format!("{DIRECT_PROXY_USERNAME}:{proxy_password}")) + ); + let proxy_url = format!("http://{DIRECT_PROXY_USERNAME}:{proxy_password}@{proxy_addr}"); + let verification_keys = launch + .verification_keys + .iter() + .map(|key| { + String::from_utf8(key.public_key_pem.clone()) + .map(|public_key_pem| GatewayVerificationKey { + key_id: key.key_id.clone(), + public_key_pem, + }) + .map_err(|error| format!("MXC verification key is not UTF-8 PEM: {error}")) + }) + .collect::, _>>()?; + let provisioning = MxcBoundarySpec { + boundary_id: context.sandbox.id.clone(), + generation: context.generation.clone(), + session_id: launch.supervisor.session_id, + session_rotation: launch.supervisor.session_rotation, + auth_epoch: launch.supervisor.auth_epoch, + gateway_id: launch.gateway_id.clone(), + verification_keys, + control_addr, + supervisor_tls: SandboxTlsClientConfig { + server_name: tls.server_name, + trust_anchor_pem: tls.trust_anchor_pem, + }, + sandbox_tls: SandboxTlsServerConfig { + certificate_chain_path: tls_cert_path, + private_key_path: tls_key_path, + }, + proxy_addr, + proxy_authorization: authorization, + proxy_url, + workload_binary: resolve_workload_binary(&context.sandbox_config.command[0])?, + child_env: sandbox_environment(&context.sandbox), } - - // Released wxc-exec BaseContainer builds cannot provision the generated - // TLS directory as a read-only share because that path fails its WRITE_DAC - // setup. Grant the sandbox-unique directory read-write instead so the - // AppContainer can actually read the injected trust paths. The directory - // contains only public CA certificates; the CA private key remains in the - // host proxy's in-memory TLS state. - let mut readwrite_paths = mapped.readwrite_paths; - append_tls_readwrite_grant(&mut readwrite_paths, host_proxy_ca_paths.as_ref()); - let readonly_paths = mapped.readonly_paths; - let filesystem = MxcFilesystem { - readwrite_paths, - readonly_paths, - // OpenShell's policy model has no explicit deny field; default-deny is - // implicit and enforced by processContainer at the OS boundary. + .provision() + .map_err(|error| error.to_string())?; + let boundary_config_path = context.boundary_state_dir.join(BOUNDARY_CONFIG_FILE); + std::fs::write( + &boundary_config_path, + provisioning + .boundary_config + .encode() + .map_err(|error| error.to_string())?, + ) + .map_err(|error| format!("write MXC boundary configuration: {error}"))?; + let auth_bundle_path = context.host_state_dir.join(HOST_AUTH_BUNDLE_FILE); + std::fs::write( + &auth_bundle_path, + serde_json::to_vec(&launch.supervisor) + .map_err(|error| format!("encode MXC supervisor auth bundle: {error}"))?, + ) + .map_err(|error| format!("write MXC supervisor auth bundle: {error}"))?; + let descriptor_path = context.host_state_dir.join(HOST_RUNTIME_DESCRIPTOR_FILE); + std::fs::write( + &descriptor_path, + provisioning + .runtime_descriptor + .backend_descriptor() + .map_err(|error| error.to_string())? + .payload, + ) + .map_err(|error| format!("write MXC runtime descriptor: {error}"))?; + // The supervisor owns the proxy listener. Release only that reservation + // before spawning it; keep the Sandbox Protocol port reserved until the + // ProcessContainer launch so unrelated local processes cannot squat it + // during the more expensive host-side setup. + drop(proxy_reservation); + let mut supervisor = spawn_supervisor(context, &descriptor_path, &auth_bundle_path)?; + let sandbox_command = encode_windows_command_line(&[ + context.config.sandbox_binary_path.clone(), + "--bootstrap".to_string(), + boundary_config_path.display().to_string(), + "--log-level".to_string(), + openshell_core::driver_utils::sandbox_log_level(&context.sandbox, "warn"), + ]); + let mut filesystem = MxcFilesystem { + readwrite_paths: mapped.readwrite_paths, + readonly_paths: mapped.readonly_paths, denied_paths: Vec::new(), }; - let command_line = encode_windows_command_line(&sandbox_config.command); - let mut environment = sandbox_environment(&sandbox); - append_tls_env_vars(&mut environment, host_proxy_ca_paths.as_ref()); - info!(sandbox = %sandbox_name, count = environment.len(), "MXC process env vars"); + push_unique_path( + &mut filesystem.readwrite_paths, + context.boundary_state_dir.display().to_string(), + ); + push_unique_path( + &mut filesystem.readonly_paths, + context.config.sandbox_binary_path.clone(), + ); let process = MxcProcess { - command_line: command_line.clone(), - cwd: sandbox_config.cwd, - env: environment, + command_line: sandbox_command, + cwd: context.sandbox_config.cwd.clone(), + env: trusted_sandbox_environment(context.config.pc_minimal_env), timeout: 0, }; - let network = proxy_addr.map(|addr| MxcNetwork { - default_policy: "block".into(), - proxy: Some(addr), - }); - - let child = match config.backend { - MxcBackend::IsolationSession => { - let iso_sandbox_id = match invoker - .provision(&config.default_configuration_id, filesystem, network) - .await - { - Ok(id) => id, - Err(error) => { - set_failed( - ®istry, - &watch_tx, - &sandbox, - &sandbox_id, - &error.to_string(), - ) - .await; - return; - } - }; - info!(sandbox = %sandbox_name, iso_id = %iso_sandbox_id, "MXC provisioned"); - { - let mut registry = registry.lock().await; - if let Some(entry) = registry.get_mut(&sandbox_id) { - // Publish cleanup identity before any later lifecycle await. - entry.iso_sandbox_id = Some(iso_sandbox_id.clone()); - entry.isolation_stopped = false; - } - } - if let Err(error) = invoker.start(&iso_sandbox_id).await { - set_failed( - ®istry, - &watch_tx, - &sandbox, - &sandbox_id, - &error.to_string(), - ) - .await; - return; - } - info!(sandbox = %sandbox_name, "MXC started"); - match invoker.spawn_exec(&iso_sandbox_id, process).await { - Ok(child) => child, - Err(error) => { - set_failed( - ®istry, - &watch_tx, - &sandbox, - &sandbox_id, - &error.to_string(), - ) - .await; - return; - } - } + let network = MxcNetwork { + default_policy: "block".to_string(), + proxy: Some(proxy_addr), + allow_local_network: context.config.pc_allow_local_network, + }; + drop(control_reservation); + let mut boundary = context + .invoker + .run_oneshot( + &context.sandbox.id, + filesystem, + MxcProcessContainer { + least_privilege: context.config.pc_least_privilege, + capabilities: context.config.pc_capabilities.clone(), + }, + process, + Some(network), + mapped.ui, + ) + .await + .map_err(|error| format!("start MXC ProcessContainer: {error}"))?; + attach_child_logs(&context.sandbox.name, "sandbox", &mut boundary); + attach_child_logs(&context.sandbox.name, "supervisor", &mut supervisor); + if context.config.etw_audit + && let Some(pid) = boundary.id() + && let Ok(process_start_key) = crate::etw_consumer::child_process_start_key(&boundary) + && let Ok(mut attribution) = context.attribution.lock() + { + attribution.register_launch( + &context.sandbox.id, + &context.sandbox.name, + pid, + process_start_key, + ); + } + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let (terminated_tx, terminated_rx) = watch::channel(false); + { + let mut registry = context.registry.lock().await; + let entry = registry + .get_mut(&context.sandbox.id) + .ok_or_else(|| "MXC sandbox was deleted during startup".to_string())?; + entry.shutdown_tx = Some(shutdown_tx); + entry.terminated_rx = Some(terminated_rx); + entry.phase_state = PhaseState::Running; + } + drop(startup_guard); + let result = monitor_runtime_pair(boundary, supervisor, shutdown_rx).await; + let _ = terminated_tx.send(true); + if let Ok(mut attribution) = context.attribution.lock() { + attribution.forget(&context.sandbox.id); + } + match result { + RuntimePairExit::Shutdown => Ok(()), + RuntimePairExit::Boundary(status) => Err(format!( + "MXC ProcessContainer exited before supervisor shutdown: {status}" + )), + RuntimePairExit::Supervisor(status) => Err(format!( + "MXC supervisor exited while ProcessContainer was active: {status}" + )), + RuntimePairExit::Wait(error) => Err(error), + } +} + +fn spawn_supervisor( + context: &LifecycleContext, + descriptor_path: &Path, + auth_bundle_path: &Path, +) -> Result { + let main_process_spec = openshell_core::sandbox_env::MainProcessConfig::encode_driver_spec( + context.sandbox.spec.as_ref(), + ) + .map_err(|error| format!("encode MXC main process spec: {error}"))?; + let mut command = Command::new(&context.config.supervisor_binary_path); + command + .kill_on_drop(true) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .arg("--role") + .arg("isolation-backend") + .arg("--backend-descriptor-file") + .arg(descriptor_path) + .arg("--auth-bundle-file") + .arg(auth_bundle_path) + .env( + openshell_core::sandbox_env::ADMITTED_ISOLATION_BACKEND, + openshell_mxc_boundary::BACKEND_NAME, + ) + .env( + openshell_core::sandbox_env::MAIN_PROCESS_SPEC, + main_process_spec, + ) + .env( + openshell_core::sandbox_env::ENDPOINT, + &context.gateway.endpoint, + ) + .env(openshell_core::sandbox_env::SANDBOX_ID, &context.sandbox.id) + .env(openshell_core::sandbox_env::SANDBOX, &context.sandbox.name) + .env( + openshell_core::sandbox_env::LOG_LEVEL, + openshell_core::driver_utils::sandbox_log_level(&context.sandbox, "warn"), + ); + if let Some(ca) = &context.gateway.tls { + command.env(openshell_core::sandbox_env::TLS_CA, ca); + } + if let Some(server_name) = &context.gateway.tls_server_name { + command.env( + openshell_core::sandbox_env::GATEWAY_TLS_SERVER_NAME, + server_name, + ); + } + command + .spawn() + .map_err(|error| format!("start host openshell-supervisor: {error}")) +} + +enum RuntimePairExit { + Shutdown, + Boundary(std::process::ExitStatus), + Supervisor(std::process::ExitStatus), + Wait(String), +} + +async fn monitor_runtime_pair( + mut boundary: Child, + mut supervisor: Child, + mut shutdown: oneshot::Receiver<()>, +) -> RuntimePairExit { + tokio::select! { + _ = &mut shutdown => { + let _ = supervisor.kill().await; + let _ = boundary.kill().await; + let _ = supervisor.wait().await; + let _ = boundary.wait().await; + RuntimePairExit::Shutdown } - MxcBackend::ProcessContainer => { - let process_container = MxcProcessContainer { - least_privilege: config.pc_least_privilege, - capabilities: config.pc_capabilities.clone(), - }; - match invoker - .run_oneshot(&sandbox_id, filesystem, process_container, process, network) - .await - { - Ok(child) => child, - Err(error) => { - set_failed( - ®istry, - &watch_tx, - &sandbox, - &sandbox_id, - &error.to_string(), - ) - .await; - return; - } - } + result = boundary.wait() => { + let _ = supervisor.kill().await; + let _ = supervisor.wait().await; + result.map_or_else( + |error| RuntimePairExit::Wait(format!("wait for MXC ProcessContainer: {error}")), + RuntimePairExit::Boundary, + ) } - }; - info!(sandbox = %sandbox_name, command = %command_line, backend = ?config.backend, "MXC agent launched"); - - let ready_sandbox = make_sandbox_with_condition( - &sandbox, - &DriverCondition { - r#type: "Ready".into(), - status: "True".into(), - reason: "AgentRunning".into(), - message: format!("Agent exec launched: {command_line}"), - transition_time: None, - }, + result = supervisor.wait() => { + let _ = boundary.kill().await; + let _ = boundary.wait().await; + result.map_or_else( + |error| RuntimePairExit::Wait(format!("wait for MXC supervisor: {error}")), + RuntimePairExit::Supervisor, + ) + } + } +} + +async fn set_failed(context: &LifecycleContext, message: &str) { + warn!(sandbox = %context.sandbox.name, %message, "MXC lifecycle failed"); + let failed = make_sandbox_with_condition( + &context.sandbox, + &condition("Ready", "False", "RuntimeFailed", message), false, ); - let (cancel_tx, cancel_rx) = watch::channel(false); + let mut registry = context.registry.lock().await; + if let Some(entry) = registry.get_mut(&context.sandbox.id) + && !matches!(entry.phase_state, PhaseState::Stopped) { - // Publish cancellation state before the monitor can observe a fast - // process exit. Holding the registry lock while spawning prevents a - // completed child from being overwritten with AgentRunning. - let mut registry_guard = registry.lock().await; - let Some(entry) = registry_guard.get_mut(&sandbox_id) else { - // The sandbox was deleted between agent launch and readiness. Bail - // without seeding ETW attribution (a stale key would misroute later - // events to a dead sandbox), without reporting Ready, and without - // spawning the exec monitor. `delete` already tore down the process. - return; - }; + entry.phase_state = PhaseState::Failed(message.to_string()); + entry.sandbox = failed.clone(); + drop(registry); + let _ = context.watch_tx.send(sandbox_event(failed)); + let _ = context.watch_tx.send(platform_event( + context.sandbox.id.clone(), + "RuntimeFailed", + message.to_string(), + )); + } +} - // Seed ETW attribution while holding the registry lock so a concurrent - // `delete` cannot remove the sandbox after we register (which would leave - // a stale key). The `wxc-exec` pid we just spawned is the collision-proof - // anchor that ties the `Sandboxing` provider's events back to this - // `sandbox_id` while the child is alive. Command text is never an - // attribution key. No-op unless the ETW consumer is running. - if config.etw_audit - && let Some(pid) = child.id() - { - match crate::etw_consumer::child_process_start_key(&child) { - Ok(process_start_key) => { - if let Ok(mut idx) = attribution.lock() { - idx.register_launch(&sandbox_id, &sandbox_name, pid, process_start_key); - } - } - Err(error) => { - warn!( - sandbox = %sandbox_name, - pid, - error, - "failed to obtain wxc-exec process generation key; PID-based ETW attribution disabled for this launch" - ); - } - } +fn sandbox_config(sandbox: &DriverSandbox) -> Result { + let config = sandbox + .spec + .as_ref() + .and_then(|spec| spec.template.as_ref()) + .and_then(|template| template.driver_config.as_ref()) + .ok_or_else(|| { + tonic::Status::invalid_argument( + "mxc requires template.driver_config.mxc with command and cwd", + ) + })?; + let config: MxcSandboxConfig = + serde_json::from_value(struct_to_json_value(config)).map_err(|error| { + tonic::Status::invalid_argument(format!("invalid mxc driver_config: {error}")) + })?; + if config.command.first().is_none_or(String::is_empty) { + return Err(tonic::Status::invalid_argument( + "mxc driver_config.command must contain an executable", + )); + } + Ok(config) +} + +fn launch_authentication( + sandbox: &DriverSandbox, +) -> Result { + let encoded = sandbox + .spec + .as_ref() + .map(|spec| spec.launch_authentication.as_slice()) + .filter(|encoded| !encoded.is_empty()) + .ok_or_else(|| { + tonic::Status::failed_precondition("MXC sandbox launch authentication is required") + })?; + serde_json::from_slice(encoded).map_err(|error| { + tonic::Status::failed_precondition(format!( + "decode MXC sandbox launch authentication: {error}" + )) + }) +} + +fn sandbox_environment(sandbox: &DriverSandbox) -> HashMap { + let mut environment = HashMap::new(); + if let Some(spec) = &sandbox.spec { + if let Some(template) = &spec.template { + environment.extend(template.environment.clone()); } + environment.extend(spec.environment.clone()); + } + environment +} - entry.sandbox = ready_sandbox.clone(); - entry.phase_state = PhaseState::Running; - entry.monitor_cancel = Some(cancel_tx); - entry.monitor_task = Some(tokio::spawn(monitor_exec( - registry.clone(), - watch_tx.clone(), - attribution.clone(), - sandbox.clone(), - sandbox_id.clone(), - cancel_rx, - child, - ))); +fn trusted_sandbox_environment(minimal: bool) -> Vec { + if minimal { + return Vec::new(); } - let _ = watch_tx.send(sandbox_event(ready_sandbox)); + ["SYSTEMROOT", "WINDIR", "PATH", "COMSPEC", "LOCALAPPDATA"] + .into_iter() + .filter_map(|key| { + std::env::var(key) + .ok() + .map(|value| format!("{key}={value}")) + }) + .collect() } -async fn monitor_exec( - registry: Arc>>, - watch_tx: Arc>, - attribution: Arc>, - sandbox: DriverSandbox, - sandbox_id: String, - mut cancel_rx: watch::Receiver, - mut child: tokio::process::Child, -) { - let wxc_pid = child.id(); - let status = tokio::select! { - status = child.wait() => Some(status), - changed = cancel_rx.changed() => { - let should_kill = changed.is_ok() && *cancel_rx.borrow_and_update(); - if should_kill { - if let Err(error) = child.kill().await { - warn!(sandbox = %sandbox.name, error = %error, "failed to terminate MXC agent process"); - } - // `kill` waits on current Tokio releases, but an explicit wait is - // harmless and guarantees the OS process handle is reaped. - let _ = child.wait().await; - } - None - } - }; +fn reserve_loopback_port() -> io::Result<(SocketAddr, std::net::TcpListener)> { + let listener = std::net::TcpListener::bind("127.0.0.1:0")?; + Ok((listener.local_addr()?, listener)) +} + +fn resolve_workload_binary(command: &str) -> Result { + let path = PathBuf::from(command); + if path.is_absolute() { + return Ok(path); + } + let output = std::process::Command::new("where.exe") + .arg(command) + .output() + .map_err(|error| format!("resolve MXC workload executable {command:?}: {error}"))?; + if !output.status.success() { + return Err(format!( + "MXC workload executable {command:?} is relative and was not found on PATH" + )); + } + String::from_utf8(output.stdout) + .map_err(|error| format!("decode resolved MXC workload executable: {error}"))? + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(PathBuf::from) + .find(|candidate| candidate.is_absolute()) + .ok_or_else(|| format!("where.exe returned no absolute path for {command:?}")) +} + +fn safe_component(value: &str) -> Result<&str, tonic::Status> { + if !value.is_empty() + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + Ok(value) + } else { + Err(tonic::Status::invalid_argument( + "sandbox ID is not safe for MXC state paths", + )) + } +} + +fn push_unique_path(paths: &mut Vec, path: String) { + if !paths + .iter() + .any(|existing| existing.eq_ignore_ascii_case(&path)) + { + paths.push(path); + } +} - // A Windows PID is authoritative only while the exact driver-owned child is - // alive. Retire it on every monitor exit path, including cancellation, before - // Windows can recycle it while the sandbox remains in the registry. - if let Some(pid) = wxc_pid - && let Ok(mut idx) = attribution.lock() +fn cleanup_runtime_directory(path: &Path) { + if let Err(error) = std::fs::remove_dir_all(path) + && error.kind() != io::ErrorKind::NotFound { - idx.retire_launch(&sandbox_id, pid); + warn!(path = %path.display(), %error, "failed to remove MXC runtime state"); + } +} + +/// Create a state directory whose DACL grants access only to the gateway's +/// Windows identity until MXC applies any explicit `ProcessContainer` grant. +/// Secret-bearing state must not inherit permissive ACLs from its parent. +fn create_restricted_state_dir(path: &Path, kind: &str) -> Result<(), String> { + std::fs::create_dir_all(path) + .map_err(|error| format!("create MXC {kind} state directory: {error}"))?; + let identity = std::process::Command::new("whoami.exe") + .args(["/user", "/fo", "csv", "/nh"]) + .output() + .map_err(|error| format!("resolve gateway Windows identity: {error}"))?; + if !identity.status.success() { + return Err("whoami failed while restricting MXC host state".to_string()); + } + let identity = String::from_utf8(identity.stdout) + .map_err(|error| format!("decode gateway Windows identity: {error}"))?; + let sid = identity + .trim() + .rsplit_once(',') + .map(|(_, sid)| sid.trim().trim_matches('"')) + .filter(|sid| sid.starts_with("S-1-")) + .ok_or_else(|| "whoami returned no Windows SID".to_string())?; + let grant = format!("*{sid}:(OI)(CI)F"); + let status = std::process::Command::new("icacls.exe") + .arg(path) + .args(["/inheritance:r", "/grant:r", &grant, "/q"]) + .status() + .map_err(|error| format!("restrict MXC host state ACL: {error}"))?; + if !status.success() { + return Err(format!( + "icacls failed to restrict MXC {kind} state directory {}", + path.display() + )); } + Ok(()) +} - let Some(status) = status else { - return; +async fn wait_for_termination( + terminated: Option>, + sandbox_name: &str, +) -> Result<(), tonic::Status> { + let Some(mut terminated) = terminated else { + return Ok(()); }; + match tokio::time::timeout(Duration::from_secs(15), terminated.wait_for(|done| *done)).await { + Ok(Ok(_)) => Ok(()), + _ => Err(tonic::Status::deadline_exceeded(format!( + "sandbox {sandbox_name} did not terminate within the MXC stop timeout" + ))), + } +} - match status { - Ok(status) if status.success() => { - info!(sandbox = %sandbox.name, "MXC agent exec completed successfully"); - let done = make_sandbox_with_condition( - &sandbox, - &DriverCondition { - r#type: "Ready".into(), - status: "True".into(), - reason: "AgentCompleted".into(), - message: "Agent exec finished successfully (exit code 0)".into(), - transition_time: None, - }, - false, - ); - let mut registry = registry.lock().await; - if let Some(entry) = registry.get_mut(&sandbox_id) { - entry.host_proxy = None; - entry.sandbox = done.clone(); - entry.phase_state = PhaseState::Running; +fn attach_child_logs(sandbox_name: &str, component: &'static str, child: &mut Child) { + if let Some(stdout) = child.stdout.take() { + let sandbox_name = sandbox_name.to_string(); + tokio::spawn(async move { + let mut lines = BufReader::new(stdout).lines(); + while let Ok(Some(line)) = lines.next_line().await { + info!(sandbox = %sandbox_name, component, "{line}"); } - drop(registry); - let _ = watch_tx.send(sandbox_event(done)); - } - Ok(status) => { - let code = status.code().unwrap_or(-1); - warn!(sandbox = %sandbox.name, exit_code = code, "MXC agent exec exited non-zero"); - let _ = watch_tx.send(platform_event( - sandbox_id.clone(), - "AgentExecFailed", - format!("agent exited with code {code}; possible out-of-policy write"), - )); - let failed = make_sandbox_with_condition( - &sandbox, - &DriverCondition { - r#type: "Ready".into(), - status: "False".into(), - reason: "ExecFailed".into(), - message: format!("Agent exec exited {code}"), - transition_time: None, - }, - false, - ); - let mut registry = registry.lock().await; - if let Some(entry) = registry.get_mut(&sandbox_id) { - entry.host_proxy = None; - entry.sandbox = failed.clone(); - entry.phase_state = PhaseState::Failed(format!("exit code {code}")); + }); + } + if let Some(stderr) = child.stderr.take() { + let sandbox_name = sandbox_name.to_string(); + tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + warn!(sandbox = %sandbox_name, component, "{line}"); } - drop(registry); - let _ = watch_tx.send(sandbox_event(failed)); - } - Err(error) => { - warn!(sandbox = %sandbox.name, error = %error, "MXC agent exec wait error"); - } + }); } } -async fn set_failed( - registry: &Arc>>, - watch_tx: &Arc>, - sandbox: &DriverSandbox, - sandbox_id: &str, - message: &str, -) { - warn!(sandbox = %sandbox.name, error = %message, "MXC lifecycle failed"); - let failed = make_sandbox_with_condition( - sandbox, - &DriverCondition { - r#type: "Ready".into(), - status: "False".into(), - reason: "ProvisionFailed".into(), - message: message.to_string(), - transition_time: None, - }, - false, - ); - let mut reg = registry.lock().await; - if let Some(entry) = reg.get_mut(sandbox_id) { - entry.host_proxy = None; - entry.sandbox = failed.clone(); - entry.phase_state = PhaseState::Failed(message.to_string()); + +fn encode_windows_command_line(args: &[String]) -> String { + args.iter() + .map(|argument| quote_windows_argument(argument)) + .collect::>() + .join(" ") +} + +fn quote_windows_argument(argument: &str) -> String { + if !argument.is_empty() + && !argument + .chars() + .any(|character| character.is_whitespace() || character == '"') + { + return argument.to_string(); + } + let mut quoted = String::from("\""); + let mut backslashes = 0; + for character in argument.chars() { + match character { + '\\' => backslashes += 1, + '"' => { + quoted.push_str(&"\\".repeat(backslashes * 2 + 1)); + quoted.push('"'); + backslashes = 0; + } + _ => { + quoted.push_str(&"\\".repeat(backslashes)); + backslashes = 0; + quoted.push(character); + } + } } - drop(reg); - let _ = watch_tx.send(sandbox_event(failed)); + quoted.push_str(&"\\".repeat(backslashes * 2)); + quoted.push('"'); + quoted } -// ── Helpers ─────────────────────────────────────────────────────────────────── +fn condition(kind: &str, status: &str, reason: &str, message: &str) -> DriverCondition { + DriverCondition { + r#type: kind.to_string(), + status: status.to_string(), + reason: reason.to_string(), + message: message.to_string(), + transition_time: None, + } +} fn make_sandbox_with_condition( base: &DriverSandbox, @@ -1232,10 +1183,6 @@ fn make_sandbox_with_condition( workspace: base.workspace.clone(), spec: base.spec.clone(), status: Some(DriverSandboxStatus { - name: base.name.clone(), - instance_id: String::new(), - agent_fd: String::new(), - sandbox_fd: String::new(), conditions: vec![condition.clone()], deleting, ..Default::default() @@ -1243,22 +1190,83 @@ fn make_sandbox_with_condition( } } -// ── Lifecycle + policy-proof tests (mock wxc-exec) ───────────────────────────── -// -// These drive the full create → provision → start → exec → self-report Ready -// flow against the in-process mock shim, proving the positive (in-policy write -// succeeds, Ready reached) and negative (out-of-policy write denied + denial -// event) paths WITHOUT the demo box. Windows-only (the crate is Windows-gated), -// run by the `windows:test:x64` mise lane. +fn sandbox_event(sandbox: DriverSandbox) -> WatchSandboxesEvent { + WatchSandboxesEvent { + payload: Some(watch_sandboxes_event::Payload::Sandbox( + WatchSandboxesSandboxEvent { + sandbox: Some(sandbox), + }, + )), + } +} + +fn deleted_event(sandbox_id: String) -> WatchSandboxesEvent { + WatchSandboxesEvent { + payload: Some(watch_sandboxes_event::Payload::Deleted( + WatchSandboxesDeletedEvent { sandbox_id }, + )), + } +} + +fn platform_event(sandbox_id: String, reason: &str, message: String) -> WatchSandboxesEvent { + WatchSandboxesEvent { + payload: Some(watch_sandboxes_event::Payload::PlatformEvent( + WatchSandboxesPlatformEvent { + sandbox_id, + event: Some(DriverPlatformEvent { + source: "mxc-driver".into(), + r#type: "Warning".into(), + reason: reason.to_string(), + message, + metadata: HashMap::new(), + ..Default::default() + }), + }, + )), + } +} + +// The RFC 0012 replacement above owns create/stop/delete/watch and paired +// process monitoring. Resource admission remains in validate_sandbox_create; +// readiness is reported through the authenticated supervisor session, not by +// the driver. Keep lifecycle/security tests below during their migration. #[cfg(test)] -mod lifecycle_tests { +mod tests { use super::*; - use futures::StreamExt; - use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; - use openshell_core::proto::{ - FilesystemPolicy, MiddlewareEndpointSelector, NetworkMiddlewareConfig, SandboxPolicy, - }; - use std::time::Duration; + + #[test] + fn gateway_connection_defaults_are_driver_owned() { + for (tls_enabled, scheme, server_name) in + [(false, "http", None), (true, "https", Some("localhost"))] + { + let ca = PathBuf::from("C:\\gateway\\ca.pem"); + let backend = MxcComputeBackend::for_gateway( + "test", + MxcComputeConfig::default(), + 9000, + tls_enabled, + Some(ca.clone()), + ); + assert_eq!( + backend.gateway.endpoint, + format!("{scheme}://127.0.0.1:9000") + ); + assert_eq!(backend.gateway.tls_server_name.as_deref(), server_name); + assert_eq!(backend.gateway.tls, Some(ca)); + assert_eq!(backend.config.grpc_endpoint, backend.gateway.endpoint); + } + } + + #[test] + fn gateway_connection_preserves_explicit_endpoint() { + let config = MxcComputeConfig { + grpc_endpoint: "https://gateway.example:9443".to_string(), + ..Default::default() + }; + let backend = MxcComputeBackend::for_gateway("test", config, 9000, true, None); + assert_eq!(backend.gateway.endpoint, "https://gateway.example:9443"); + assert_eq!(backend.gateway.tls_server_name, None); + } fn host_grants_config() -> MxcComputeConfig { MxcComputeConfig { @@ -1273,110 +1281,149 @@ mod lifecycle_tests { #[tokio::test] async fn admission_rejects_host_grants_even_with_driver_config_enabled() { - let backend = MxcComputeBackend::new_mocked(MxcComputeConfig { - allow_driver_config: true, - ..Default::default() - }); - let sandbox = driver_sandbox("sb-admission"); + let (mut backend, sandbox, _dir) = preflight_fixture(); + backend.config.resource_admission.enabled = true; let error = backend.validate_sandbox_create(&sandbox).unwrap_err(); assert_eq!(error.code(), tonic::Code::FailedPrecondition); assert!(backend.create_sandbox(&sandbox).await.is_err()); - assert!(backend.get_sandbox("sb-admission").await.is_none()); + assert!(backend.get_sandbox(&sandbox.id).await.is_none()); + assert!(!backend.config.state_dir.exists()); } - fn driver_sandbox(id: &str) -> DriverSandbox { - driver_sandbox_with_command(id, "", vec!["cmd".into(), "/c".into(), "exit 0".into()]) + #[test] + fn static_configuration_requires_an_absolute_wxc_path() { + for path in ["", "wxc-exec.exe", "relative\\wxc-exec.exe"] { + let config = MxcComputeConfig { + wxc_exec_path: path.to_string(), + ..Default::default() + }; + assert!(config.validate_configuration().is_err()); + } + let config = MxcComputeConfig { + wxc_exec_path: "C:\\mxc-kit\\bin\\wxc-exec.exe".to_string(), + ..Default::default() + }; + assert!(config.validate_configuration().is_ok()); } - fn driver_sandbox_with_command(id: &str, cwd: &str, command: Vec) -> DriverSandbox { - let serde_json::Value::Object(driver_config) = serde_json::json!({ - "command": command, - "cwd": cwd, + fn preflight_fixture() -> (MxcComputeBackend, DriverSandbox, tempfile::TempDir) { + use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + let dir = tempfile::tempdir().expect("test directory"); + let binary = std::env::current_exe().expect("test executable"); + let backend = MxcComputeBackend::new( + openshell_core::config::DEFAULT_GATEWAY_NAME, + MxcComputeConfig { + wxc_exec_path: binary.display().to_string(), + grpc_endpoint: "http://127.0.0.1:1".into(), + supervisor_binary_path: binary.display().to_string(), + sandbox_binary_path: binary.display().to_string(), + state_dir: dir.path().join("not-created"), + ..host_grants_config() + }, + ); + let serde_json::Value::Object(config) = serde_json::json!({ + "command": ["C:\\Windows\\System32\\cmd.exe", "/c", "exit 0"], + "cwd": dir.path(), }) else { - unreachable!(); + unreachable!() }; - DriverSandbox { - id: id.to_string(), - name: id.to_string(), - namespace: String::new(), - workspace: String::new(), + let sandbox = DriverSandbox { + id: "preflight".into(), + name: "preflight".into(), spec: Some(DriverSandboxSpec { - sandbox_token: "test-token".into(), + policy: Some(SandboxPolicy { + version: 1, + ..Default::default() + }), template: Some(DriverSandboxTemplate { driver_config: Some( - openshell_core::proto_struct::json_object_to_struct(driver_config).unwrap(), + openshell_core::proto_struct::json_object_to_struct(config) + .expect("config"), ), ..Default::default() }), ..Default::default() }), - status: None, - } - } - fn fs_policy(read_write: &[&str]) -> SandboxPolicy { - SandboxPolicy { - filesystem: Some(FilesystemPolicy { - include_workdir: false, - read_only: Vec::new(), - read_write: read_write.iter().map(ToString::to_string).collect(), - }), ..Default::default() - } + }; + (backend, sandbox, dir) } - fn with_policy(mut sandbox: DriverSandbox, policy: SandboxPolicy) -> DriverSandbox { - sandbox.spec.as_mut().unwrap().policy = Some(policy); - sandbox + #[test] + fn preflight_does_not_require_not_yet_minted_authentication() { + let (backend, sandbox, _dir) = preflight_fixture(); + backend + .validate_sandbox_create(&sandbox) + .expect("static preflight"); + assert!(!backend.config.state_dir.exists()); } - fn ready_condition(sb: &DriverSandbox) -> Option { - sb.status - .as_ref()? - .conditions - .iter() - .find(|c| c.r#type == "Ready") - .cloned() + #[tokio::test] + async fn provisioning_rejects_missing_authentication_before_side_effects() { + let (backend, sandbox, _dir) = preflight_fixture(); + let error = backend + .create_sandbox(&sandbox) + .await + .expect_err("authentication required"); + assert_eq!(error.code(), tonic::Code::FailedPrecondition); + assert!(error.message().contains("launch authentication")); + assert!(!backend.config.state_dir.exists()); + assert!(backend.get_sandbox(&sandbox.id).await.is_none()); } - /// Poll the backend registry until the predicate matches or the deadline hits. - async fn wait_for( - backend: &MxcComputeBackend, - name: &str, - mut pred: F, - ) -> Option - where - F: FnMut(&DriverSandbox) -> bool, - { - for _ in 0..100 { - if let Some(sandbox) = backend.get_sandbox(name).await - && pred(&sandbox) - { - return Some(sandbox); - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - None + #[test] + fn capabilities_delegate_readiness_to_supervisor() { + let backend = MxcComputeBackend::new( + openshell_core::config::DEFAULT_GATEWAY_NAME, + MxcComputeConfig::default(), + ); + assert!(!backend.capabilities().driver_reports_runtime_readiness); } #[test] - fn mxc_config_defaults_to_default_deny_process_container() { - let config = MxcComputeConfig::default(); + fn legacy_isolation_setting_does_not_change_processcontainer_contract() { + let config: MxcComputeConfig = serde_json::from_value(serde_json::json!({ + "backend": "process_container", + "default_configuration_id": "composable", + })) + .expect("legacy ProcessContainer config"); assert_eq!(config.backend, MxcBackend::ProcessContainer); - assert!(!config.egress_proxy); - assert!(config.egress_proxy_addr.is_empty()); + assert_eq!( + config.default_configuration_id.as_deref(), + Some("composable") + ); + assert!( + serde_json::to_value(&config) + .expect("config") + .get("default_configuration_id") + .is_none() + ); + let (mut backend, sandbox, _dir) = preflight_fixture(); + backend.config.backend = MxcBackend::IsolationSession; + backend.config.default_configuration_id = config.default_configuration_id; + assert!( + backend + .validate_sandbox_create(&sandbox) + .expect_err("IsolationSession still unsupported") + .message() + .contains("requires process_container") + ); } #[test] - fn sandbox_proxy_addr_uses_ephemeral_loopback_port() { - let configured = "127.0.0.1:18080".parse().unwrap(); - let (addr, _reservation) = allocate_sandbox_proxy_addr(configured).unwrap(); - assert_eq!(addr.ip(), configured.ip()); - assert_ne!(addr.port(), 0); + fn command_line_quotes_spaces() { + assert_eq!( + encode_windows_command_line(&[ + "C:\\Program Files\\OpenShell\\openshell-windows-sandbox.exe".to_string(), + "--bootstrap".to_string(), + ]), + "\"C:\\Program Files\\OpenShell\\openshell-windows-sandbox.exe\" --bootstrap" + ); } #[test] - fn sandbox_environment_inherits_host_with_spec_precedence() { - let mut sandbox = driver_sandbox("sb-env"); + fn sandbox_environment_preserves_spec_precedence_without_host_secrets() { + let (_backend, mut sandbox, _dir) = preflight_fixture(); let spec = sandbox.spec.as_mut().unwrap(); spec.template .as_mut() @@ -1384,64 +1431,23 @@ mod lifecycle_tests { .environment .insert("SHARED".into(), "template".into()); spec.environment.insert("SHARED".into(), "spec".into()); - spec.environment.insert("TOKEN".into(), "value".into()); - let environment = sandbox_environment(&sandbox); - assert!(environment.contains(&"SHARED=spec".to_string())); - assert!(environment.contains(&"TOKEN=value".to_string())); - for key in MINIMAL_WINDOWS_BOOTSTRAP_ENV { - if let Ok(value) = std::env::var(key) { - assert!(environment.contains(&format!("{key}={value}"))); - } - } - assert!(environment.iter().all(|entry| { - let key = entry.split_once('=').map_or(entry.as_str(), |(key, _)| key); - key == "SHARED" || key == "TOKEN" || MINIMAL_WINDOWS_BOOTSTRAP_ENV.contains(&key) - })); - } - - #[test] - fn tls_env_vars_replace_user_trust_overrides() { - let tls_dir = std::env::temp_dir().join("openshell-mxc-tls-test"); - let ca_cert = tls_dir.join("openshell-ca.pem"); - let bundle = tls_dir.join("ca-bundle.pem"); - let ca_cert_path = ca_cert.display().to_string(); - let bundle_path = bundle.display().to_string(); - let mut env = vec![ - "FOO=bar".to_string(), - "SSL_CERT_FILE=C:\\old\\bundle.pem".to_string(), - "node_extra_ca_certs=C:\\old\\ca.pem".to_string(), - ]; - - append_tls_env_vars(&mut env, Some(&(ca_cert, bundle))); - - assert!(env.contains(&"FOO=bar".to_string())); - assert!( - !env.iter() - .any(|entry| entry == "SSL_CERT_FILE=C:\\old\\bundle.pem") - ); - assert!( - !env.iter() - .any(|entry| entry == "node_extra_ca_certs=C:\\old\\ca.pem") + spec.environment.insert("TOKEN".into(), "explicit".into()); + assert_eq!( + sandbox_environment(&sandbox), + HashMap::from([ + ("SHARED".into(), "spec".into()), + ("TOKEN".into(), "explicit".into()), + ]) ); - assert!(env.contains(&format!("NODE_EXTRA_CA_CERTS={ca_cert_path}"))); - assert!(env.contains(&format!("DENO_CERT={ca_cert_path}"))); - assert!(env.contains(&format!("SSL_CERT_FILE={bundle_path}"))); - assert!(env.contains(&format!("REQUESTS_CA_BUNDLE={bundle_path}"))); - assert!(env.contains(&format!("CURL_CA_BUNDLE={bundle_path}"))); - assert!(env.contains(&format!("GIT_SSL_CAINFO={bundle_path}"))); } #[test] - fn tls_readwrite_grant_adds_ca_directory_once() { - let tls_dir = std::env::temp_dir().join("openshell-mxc-tls-test"); - let ca_cert = tls_dir.join("openshell-ca.pem"); - let bundle = tls_dir.join("ca-bundle.pem"); - let existing = tls_dir.display().to_string().to_ascii_lowercase(); - let mut readwrite = vec![existing.clone()]; - - append_tls_readwrite_grant(&mut readwrite, Some(&(ca_cert, bundle))); - - assert_eq!(readwrite, vec![existing]); + fn proxy_port_is_reserved_on_loopback() { + let (address, reservation) = reserve_loopback_port().unwrap(); + assert!(address.ip().is_loopback()); + assert_ne!(address.port(), 0); + assert!(std::net::TcpListener::bind(address).is_err()); + drop(reservation); } #[test] @@ -1458,384 +1464,32 @@ mod lifecycle_tests { quote_windows_argument(r#"say "hello""#), r#""say \"hello\"""# ); - assert_eq!( - quote_windows_argument("trailing slash\\ "), - r#""trailing slash\ ""# - ); - } - #[tokio::test] - async fn positive_in_policy_write_reaches_ready_and_materializes_file() { - let tmp = tempfile::tempdir().unwrap(); - let share = tmp.path().to_string_lossy().replace('\\', "/"); - let hello = format!("{share}/hello.txt"); - let cmd = vec![ - "powershell".into(), - "-NoProfile".into(), - "-Command".into(), - format!("Set-Content -LiteralPath {hello} -Value hi"), - ]; - let backend = MxcComputeBackend::new_mocked(host_grants_config()); - - let policy = fs_policy(&[&share]); - let sb = with_policy(driver_sandbox_with_command("sb-pos", &share, cmd), policy); - backend.create_sandbox(&sb).await.expect("create accepted"); - - // Self-reported Ready=True (no supervisor) once the agent exec launches. - let ready = wait_for(&backend, "sb-pos", |s| { - ready_condition(s).is_some_and(|c| c.status == "True" && c.reason == "AgentRunning") - }) - .await; - assert!(ready.is_some(), "sandbox should self-report Ready=True"); - - // Positive proof: the in-policy write materializes the host artifact. - let host_path = std::path::Path::new(tmp.path()).join("hello.txt"); - let mut found = false; - for _ in 0..100 { - if host_path.exists() { - found = true; - break; - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - assert!(found, "hello.txt should appear in the granted share folder"); - - // A successful one-shot agent (exit 0) must STAY Ready, not demote to - // Error. Assert the terminal condition is Ready=True/AgentCompleted so the - // positive demo shows a green Ready phase, not a red Error. - let completed = wait_for(&backend, "sb-pos", |s| { - ready_condition(s).is_some_and(|c| c.status == "True" && c.reason == "AgentCompleted") - }) - .await; - assert!( - completed.is_some(), - "sandbox should remain Ready=True (AgentCompleted) after a successful exec, never demote to Error" - ); - assert!( - !backend - .attribution - .lock() - .unwrap() - .has_live_pid_for_sandbox("sb-pos"), - "the process monitor must retire the wxc-exec PID before publishing completion" - ); - } - - #[tokio::test] - async fn processcontainer_one_shot_in_policy_write_reaches_ready() { - // The processContainer backend skips provision/start and runs a single - // one-shot. The mock routes through `run_oneshot`, deriving grants from - // the filesystem (not a provision step), so the in-policy write should - // materialize and the sandbox should reach Ready=True. - let tmp = tempfile::tempdir().unwrap(); - let share = tmp.path().to_string_lossy().replace('\\', "/"); - let hello = format!("{share}/hello.txt"); - let cmd = vec![ - "powershell".into(), - "-NoProfile".into(), - "-Command".into(), - format!("Set-Content -LiteralPath {hello} -Value hi"), - ]; - let backend = MxcComputeBackend::new_mocked(host_grants_config()); - - let policy = fs_policy(&[&share]); - let sb = with_policy(driver_sandbox_with_command("sb-pc", &share, cmd), policy); - backend.create_sandbox(&sb).await.expect("create accepted"); - - let ready = wait_for(&backend, "sb-pc", |s| { - ready_condition(s).is_some_and(|c| c.status == "True" && c.reason == "AgentRunning") - }) - .await; - assert!( - ready.is_some(), - "processContainer sandbox should self-report Ready=True" - ); - let recorded = crate::mxc::mock_recorded_config("sb-pc").expect("mock recorded config"); - assert!( - recorded.get("network").is_none(), - "coarse path must not emit an MXC network block" - ); - - let host_path = std::path::Path::new(tmp.path()).join("hello.txt"); - let mut found = false; - for _ in 0..100 { - if host_path.exists() { - found = true; - break; - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - assert!( - found, - "in-policy write should materialize under processContainer" - ); } #[tokio::test] - async fn split_path_provisions_with_proxy_redirect() { - use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule}; - - let tmp = tempfile::tempdir().unwrap(); - let share = tmp.path().to_string_lossy().replace('\\', "/"); - let hello = format!("{share}/hello.txt"); - let cmd = vec![ - "powershell".into(), - "-NoProfile".into(), - "-Command".into(), - format!("Set-Content -LiteralPath {hello} -Value hi"), - ]; - let config = MxcComputeConfig { - backend: MxcBackend::ProcessContainer, - egress_proxy: true, - egress_proxy_addr: "127.0.0.1:18080".into(), - ..host_grants_config() + async fn stop_terminates_and_reaps_both_runtime_processes() { + let spawn = || { + Command::new("powershell.exe") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "Start-Sleep -Seconds 60", + ]) + .kill_on_drop(true) + .spawn() + .expect("real test process") }; - let backend = MxcComputeBackend::new_mocked(config); - let mut stream = backend.watch_sandboxes().await; - - let mut policy = fs_policy(&[&share]); - policy.network_policies.insert( - "api".into(), - NetworkPolicyRule { - name: "api".into(), - endpoints: vec![NetworkEndpoint { - host: "example.com".into(), - ports: vec![443], - protocol: "rest".into(), - ..Default::default() - }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".into(), - }], - }, - ); - let sandbox = with_policy( - driver_sandbox_with_command("sb-egress", &share, cmd), - policy.clone(), - ); - backend - .create_sandbox(&sandbox) - .await - .expect("create accepted"); - - let ready = wait_for(&backend, "sb-egress", |s| { - ready_condition(s).is_some_and(|c| c.status == "True" && c.reason == "AgentRunning") - }) - .await; - assert!( - ready.is_some(), - "egress split sandbox should reach Ready=True" - ); - - let recorded = crate::mxc::mock_recorded_config("sb-egress").expect("mock recorded config"); - assert_eq!(recorded["network"]["defaultPolicy"], "block"); - assert!(recorded["network"].get("allowedHosts").is_none()); - assert!(recorded["network"].get("blockedHosts").is_none()); - // MXC 0.6.0-alpha accepts only {"proxy": {"localhost": N}}. - let proxy_port = recorded["network"]["proxy"]["localhost"] - .as_u64() - .expect("proxy localhost port"); - assert!(proxy_port > 0); - assert!(u16::try_from(proxy_port).is_ok()); - assert!( - recorded["network"]["proxy"].get("host").is_none(), - "proxy must not contain 'host' key" - ); - assert!( - recorded["network"]["proxy"].get("port").is_none(), - "proxy must not contain 'port' key" - ); - - let reg = backend.registry.lock().await; - let entry = reg.get("sb-egress").expect("registry entry"); - let entry_proxy_addr = entry.proxy_addr.expect("proxy addr"); - assert_eq!( - entry_proxy_addr.ip(), - std::net::IpAddr::from([127, 0, 0, 1]) - ); - assert_eq!(u64::from(entry_proxy_addr.port()), proxy_port); - assert_eq!( - entry.trimmed_policy.as_ref().unwrap().network_policies, - policy.network_policies - ); - drop(reg); - - let mut saw_redirect = false; - let deadline = tokio::time::Instant::now() + Duration::from_secs(15); - while tokio::time::Instant::now() < deadline { - match tokio::time::timeout(Duration::from_millis(500), stream.next()).await { - Ok(Some(Ok(ev))) => { - if let Some(watch_sandboxes_event::Payload::PlatformEvent(pe)) = ev.payload - && pe - .event - .as_ref() - .is_some_and(|e| e.reason == "EgressRedirect") - { - saw_redirect = true; - break; - } - } - Ok(_) => break, - Err(_) => {} - } - } - assert!(saw_redirect, "expected EgressRedirect platform event"); - } - - #[tokio::test] - async fn negative_out_of_policy_write_is_denied_with_event() { - let share_tmp = tempfile::tempdir().unwrap(); - let out_tmp = tempfile::tempdir().unwrap(); - let share = share_tmp.path().to_string_lossy().replace('\\', "/"); - let out_path = format!( - "{}/hello.txt", - out_tmp.path().to_string_lossy().replace('\\', "/") - ); - let cmd = vec![ - "powershell".into(), - "-NoProfile".into(), - "-Command".into(), - format!("Set-Content -LiteralPath {out_path} -Value hi"), - ]; - let backend = MxcComputeBackend::new_mocked(host_grants_config()); - - // Subscribe to the watch stream BEFORE create so we catch the denial event. - let mut stream = backend.watch_sandboxes().await; - - let policy = fs_policy(&[&share]); - let sandbox = with_policy(driver_sandbox_with_command("sb-neg", &share, cmd), policy); - backend - .create_sandbox(&sandbox) - .await - .expect("create accepted"); - - // Collect events until we observe the AgentExecFailed platform event. - let mut saw_denial = false; - let deadline = tokio::time::Instant::now() + Duration::from_secs(15); - while tokio::time::Instant::now() < deadline { - match tokio::time::timeout(Duration::from_millis(500), stream.next()).await { - Ok(Some(Ok(ev))) => { - if let Some(watch_sandboxes_event::Payload::PlatformEvent(event)) = ev.payload - && event - .event - .as_ref() - .is_some_and(|event| event.reason == "AgentExecFailed") - { - saw_denial = true; - break; - } - } - Ok(_) => break, - Err(_) => {} - } - } - assert!( - saw_denial, - "expected an AgentExecFailed denial platform event" - ); - - // The out-of-policy artifact must NOT have been written by the mock. - let out_fs = std::path::Path::new(out_tmp.path()).join("hello.txt"); - assert!(!out_fs.exists(), "out-of-policy write must be denied"); - - // And the sandbox surfaces a terminal ExecFailed Ready=False condition. - let failed = wait_for(&backend, "sb-neg", |s| { - ready_condition(s).is_some_and(|c| c.status == "False" && c.reason == "ExecFailed") - }) - .await; - assert!(failed.is_some(), "sandbox should report ExecFailed"); - } - - #[tokio::test] - async fn stop_terminates_and_reaps_a_running_process_container() { - let tmp = tempfile::tempdir().unwrap(); - let share = tmp.path().to_string_lossy().replace('\\', "/"); - let command = vec![ - "powershell".into(), - "-NoProfile".into(), - "-Command".into(), - format!("$null = '{share}'; Start-Sleep -Seconds 60"), - ]; - let backend = MxcComputeBackend::new_mocked(host_grants_config()); - let policy = fs_policy(&[&share]); - let sandbox = with_policy(driver_sandbox_with_command("sb-stop", "", command), policy); - backend - .create_sandbox(&sandbox) - .await - .expect("create accepted"); - wait_for(&backend, "sb-stop", |sandbox| { - ready_condition(sandbox).is_some_and(|condition| condition.reason == "AgentRunning") - }) + let boundary = spawn(); + let supervisor = spawn(); + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + shutdown_tx.send(()).unwrap(); + let exit = tokio::time::timeout( + Duration::from_secs(5), + monitor_runtime_pair(boundary, supervisor, shutdown_rx), + ) .await - .expect("long-running child should start"); - tokio::time::sleep(Duration::from_millis(250)).await; - let running = backend.get_sandbox("sb-stop").await.unwrap(); - assert_eq!(ready_condition(&running).unwrap().reason, "AgentRunning"); - - tokio::time::timeout(Duration::from_secs(5), backend.stop_sandbox("sb-stop")) - .await - .expect("stop should not wait for the child sleep") - .expect("stop should terminate and reap the child"); - let stopped = backend.get_sandbox("sb-stop").await.unwrap(); - assert_eq!(ready_condition(&stopped).unwrap().reason, "Stopped"); - } - - #[tokio::test] - async fn unmappable_network_policy_fails_create_lifecycle() { - use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule}; - let tmp = tempfile::tempdir().unwrap(); - let share = tmp.path().to_string_lossy().replace('\\', "/"); - - let backend = MxcComputeBackend::new_mocked(host_grants_config()); - - let mut policy = fs_policy(&[&share]); - policy.network_policies.insert( - "api".into(), - NetworkPolicyRule { - name: "api".into(), - endpoints: vec![NetworkEndpoint { - host: "example.com".into(), - ..Default::default() - }], - binaries: Vec::new(), - }, - ); - let sandbox = with_policy(driver_sandbox("sb-net"), policy); - let error = backend - .create_sandbox(&sandbox) - .await - .expect_err("unmappable policy must fail CreateSandbox synchronously"); - assert_eq!(error.code(), tonic::Code::InvalidArgument); - assert!(backend.get_sandbox("sb-net").await.is_none()); - } - - #[tokio::test] - async fn governed_egress_rejects_network_middleware_before_lifecycle() { - let config = MxcComputeConfig { - egress_proxy: true, - egress_proxy_addr: "127.0.0.1:18080".into(), - ..host_grants_config() - }; - let backend = MxcComputeBackend::new_mocked(config); - - let mut policy = fs_policy(&[]); - policy.network_middlewares.insert( - "redactor".into(), - NetworkMiddlewareConfig { - name: "redactor".into(), - middleware: "openshell/regex".into(), - on_error: "fail_closed".into(), - endpoints: Some(MiddlewareEndpointSelector { - include: vec!["api.example.com".into()], - exclude: Vec::new(), - }), - ..Default::default() - }, - ); - let sandbox = with_policy(driver_sandbox("sb-middleware"), policy); - - let error = backend.create_sandbox(&sandbox).await.unwrap_err(); - assert_eq!(error.code(), tonic::Code::InvalidArgument); - assert!(error.message().contains("network_middlewares")); - assert!(backend.get_sandbox("sb-middleware").await.is_none()); + .expect("stop reaps both processes promptly"); + assert!(matches!(exit, RuntimePairExit::Shutdown)); } } diff --git a/crates/openshell-driver-mxc/src/isolation.rs b/crates/openshell-driver-mxc/src/isolation.rs new file mode 100644 index 0000000000..f18a4077e1 --- /dev/null +++ b/crates/openshell-driver-mxc/src/isolation.rs @@ -0,0 +1,150 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! MXC provisioning for the common authenticated Sandbox Protocol. + +use std::collections::{BTreeMap, HashMap}; +use std::net::SocketAddr; +use std::path::PathBuf; + +use openshell_isolation_interface::contract::{ + BackendError, BinaryIdentity, DirectProxyConfiguration, ExecutableIdentity, + OuterFenceGuarantees, ResolvedWorkloadIdentity, +}; +use openshell_sandbox_backend::boundary_protocol::{ + BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, + SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, +}; +use serde::Serialize; + +#[derive(Serialize)] +#[allow(clippy::struct_excessive_bools)] // separately serialized enforcement properties, not state flags +struct MxcOuterFenceEvidence<'a> { + generation: &'a str, + containment: &'a str, + default_deny_filesystem: bool, + default_deny_egress: bool, + loopback_proxy_only: bool, + controller_loss_fails_closed: bool, +} + +pub struct MxcBoundarySpec { + pub boundary_id: String, + pub generation: String, + pub session_id: openshell_core::SandboxSessionId, + pub session_rotation: openshell_core::jwt::SessionRotation, + pub auth_epoch: openshell_core::jwt::CredentialEpoch, + pub gateway_id: String, + pub verification_keys: Vec, + pub control_addr: SocketAddr, + pub supervisor_tls: SandboxTlsClientConfig, + pub sandbox_tls: SandboxTlsServerConfig, + pub proxy_addr: SocketAddr, + pub proxy_authorization: String, + pub proxy_url: String, + pub workload_binary: PathBuf, + pub child_env: HashMap, +} + +pub struct MxcBoundaryProvisioning { + pub boundary_config: BoundaryConfig, + pub runtime_descriptor: SandboxRuntimeDescriptor, +} + +impl MxcBoundarySpec { + pub fn provision(self) -> Result { + if !self.control_addr.ip().is_loopback() + || !self.proxy_addr.ip().is_loopback() + || self.control_addr.port() == 0 + || self.proxy_addr.port() == 0 + { + return Err(BackendError::Descriptor( + "MXC control and proxy listeners must use concrete loopback ports".to_string(), + )); + } + let resource_digest = format!("mxc-processcontainer:{}", self.generation); + // The common identity envelope is numeric for Unix backends. MXC binds + // its AppContainer token through the source and resource digest while + // using reserved nonzero numeric sentinels for the common fields. + let workload_identity = ResolvedWorkloadIdentity::new( + 1, + 1, + Vec::new(), + "mxc-appcontainer".to_string(), + resource_digest, + )?; + let resource_claims = BTreeMap::from([ + ("mxc.generation".to_string(), self.generation.clone()), + ( + "mxc.appcontainer_profile".to_string(), + self.boundary_id.clone(), + ), + ]); + let evidence = serde_json::to_vec(&MxcOuterFenceEvidence { + generation: &self.generation, + containment: "process_container", + default_deny_filesystem: true, + default_deny_egress: true, + loopback_proxy_only: true, + controller_loss_fails_closed: true, + }) + .map_err(|error| { + BackendError::Descriptor(format!("encode MXC outer-fence evidence: {error}")) + })?; + // Configuration intent is not enforcement evidence. In particular, + // allowing all host loopback ports does not prove NoUnmanagedEgressPath, + // and this launch path has no verified live-revocation mechanism. + // Keep main's confirmation gate closed until native evidence can + // establish every required guarantee. + let outer_fence = + OuterFenceGuarantees::from_enforcement_evidence(&self.generation, [], &evidence)?; + let direct_proxy = DirectProxyConfiguration { + bind_addr: self.proxy_addr, + authorization: self.proxy_authorization, + binary_identity: BinaryIdentity { + executable: ExecutableIdentity { + path: self.workload_binary, + digest: None, + }, + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + }, + }; + Ok(MxcBoundaryProvisioning { + boundary_config: BoundaryConfig { + boundary_id: self.boundary_id.clone(), + generation: self.generation.clone(), + session_id: self.session_id, + session_rotation: self.session_rotation, + auth_epoch: self.auth_epoch, + gateway_id: self.gateway_id, + verification_keys: self.verification_keys, + listener: BoundaryListener::TlsTcp { + address: self.control_addr, + tls: self.sandbox_tls, + }, + resource_claims: resource_claims.clone(), + resource_claim_files: BTreeMap::new(), + workload_identity: workload_identity.clone(), + outer_fence: outer_fence.clone(), + direct_proxy_url: Some(self.proxy_url), + child_env: self.child_env, + }, + runtime_descriptor: SandboxRuntimeDescriptor { + boundary_id: self.boundary_id, + generation: self.generation, + session_id: self.session_id, + workload_identity, + transport: SandboxTransport::Tcp { + authority: self.control_addr.to_string(), + addresses: vec![self.control_addr], + }, + tls: self.supervisor_tls, + host_gateway_ip: Some(self.proxy_addr.ip()), + direct_proxy: Some(direct_proxy), + resource_claims, + outer_fence, + }, + }) + } +} diff --git a/crates/openshell-driver-mxc/src/lib.rs b/crates/openshell-driver-mxc/src/lib.rs index 4b9d328e9f..9b0850b456 100644 --- a/crates/openshell-driver-mxc/src/lib.rs +++ b/crates/openshell-driver-mxc/src/lib.rs @@ -4,10 +4,12 @@ //! `OpenShell` MXC compute driver. //! //! Implements the gateway's `ComputeDriver` gRPC contract backed by Microsoft -//! MXC (`wxc-exec`) on Windows. The driver is **in-process**, runs the agent -//! directly (exec-in-driver), and self-reports `Ready` — there is no -//! in-sandbox supervisor, no host-side surrogate, and no `ConnectSupervisor` -//! relay. +//! MXC (`wxc-exec`) on Windows. The in-process driver provisions an +//! `openshell-windows-sandbox` boundary inside the `ProcessContainer` and launches the +//! standard `openshell-supervisor --role=isolation-backend` on the host. The +//! pair communicates over the authenticated Sandbox Protocol; workload +//! lifecycle, forwarding, credentials, and governed networking therefore use +//! the same supervisor session as the other RFC 0012 isolation backends. //! //! This crate compiles to an **empty stub** on non-Windows targets so the //! Linux build stays green. All implementation code is gated on @@ -20,6 +22,8 @@ mod driver; #[cfg(target_os = "windows")] mod grpc; #[cfg(target_os = "windows")] +mod isolation; +#[cfg(target_os = "windows")] mod mxc; #[cfg(target_os = "windows")] mod policy; diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs index 0a77565f83..99a3e6fcef 100644 --- a/crates/openshell-driver-mxc/src/mxc.rs +++ b/crates/openshell-driver-mxc/src/mxc.rs @@ -1,33 +1,23 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! `wxc-exec` invoker and MXC request/response types. -//! -//! Builds state-aware MXC config JSON, base64-encodes it, runs `wxc-exec`, -//! and parses the response envelope. The exec phase is special: its stdout is -//! live process output (not JSON) and its exit code is the agent exit code. +//! `wxc-exec` `ProcessContainer` launcher and request types. use base64::Engine as _; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use serde::Serialize; use std::net::SocketAddr; use std::path::PathBuf; -use std::sync::{Mutex, OnceLock}; use thiserror::Error; use tokio::process::Command; -use tracing::debug; +use tracing::{debug, info}; -/// MXC config schema version. -pub const MXC_SCHEMA_VERSION: &str = "0.6.0-alpha"; - -/// Default `configurationId` for isolation session. Never use `"small"` (known OS bug). -pub const DEFAULT_CONFIGURATION_ID: &str = "composable"; +/// MXC config schema version. The mapper and one-shot launcher share the +/// MXC 0.8 directional network schema. +pub const MXC_SCHEMA_VERSION: &str = "0.8.0-alpha"; /// Environment flag selecting the in-process mock `wxc-exec` shim. When set to -/// `"1"`, the invoker does NOT spawn the real `wxc-exec.exe`; instead it emits -/// canned provision/start/stop/deprovision results and simulates `AppContainer` -/// filesystem-policy enforcement for the exec phase. This is what makes the -/// full create → Ready → policy-proof round trip runnable off the demo box. +/// `"1"`, the invoker does not spawn `wxc-exec.exe`. This wiring shim has no +/// `AppContainer` token and must fail runtime audit before workload execution. pub const MOCK_ENV_VAR: &str = "OPENSHELL_MXC_MOCK_WXC"; fn mock_enabled() -> bool { @@ -40,22 +30,11 @@ fn mock_normalize(s: &str) -> String { s.replace('/', "\\").to_lowercase() } -/// Per-process mock state: `iso:` sandbox id → granted read-write paths -/// (normalized). Populated by the mock provision, consumed by the mock exec to -/// decide whether the agent's write target is in-policy. -fn mock_grants() -> &'static Mutex>> { - static GRANTS: OnceLock>>> = OnceLock::new(); - GRANTS.get_or_init(|| Mutex::new(HashMap::new())) -} - // ── Request types ───────────────────────────────────────────────────────────── /// Filesystem shares for the sandbox. /// -/// `isolation_session` honors `readwrite`/`readonly` (grant-only — it has no -/// deny primitive). `processContainer` additionally honors `denied_paths` -/// because the `AppContainer` backend can stamp deny ACEs; it is also genuinely -/// default-deny, so anything not granted is already inaccessible. +/// `ProcessContainer` honors `readwrite`/`readonly` grants and `denied_paths`. #[derive(Debug, Default)] #[allow(clippy::struct_field_names)] pub struct MxcFilesystem { @@ -69,6 +48,46 @@ pub struct MxcFilesystem { pub struct MxcNetwork { pub default_policy: String, pub proxy: Option, + /// When true, includes `"allowLocalNetwork": true` in the network JSON. + /// Required for node.js to initialize inside a processcontainer — without + /// it, node.exe DLL initialization fails with `STATUS_DLL_INIT_FAILED`. + pub allow_local_network: bool, +} + +/// Directional clipboard access in the MXC top-level `ui` policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MxcClipboardAccess { + None, + Read, + Write, + All, +} + +impl MxcClipboardAccess { + const fn as_str(self) -> &'static str { + match self { + Self::None => "none", + Self::Read => "read", + Self::Write => "write", + Self::All => "all", + } + } +} + +/// Cross-platform MXC UI policy emitted for a process container. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MxcUi { + pub disable: bool, + pub clipboard: MxcClipboardAccess, + pub injection: bool, +} + +fn ui_json(ui: &MxcUi) -> serde_json::Value { + serde_json::json!({ + "disable": ui.disable, + "clipboard": ui.clipboard.as_str(), + "injection": ui.injection, + }) } /// `processContainer`-specific knobs (one-shot `AppContainer` backend). @@ -92,48 +111,66 @@ pub struct MxcProcess { pub timeout: u64, } -fn network_json(network: &MxcNetwork) -> serde_json::Value { - // MXC 0.6.0-alpha schema accepts ONLY {"proxy": {"localhost": }}. - // {"host": ..., "port": ...} and every other shape is rejected — verified - // empirically against the real wxc-exec 0.6.0-alpha binary via --dry-run. - // See also docs/reference/mxc-compute-driver-design.mdx §network.proxy. - // The MxcNetwork.proxy field remains SocketAddr so callers keep full - // precision; only the port is serialized into the localhost key. - // Released wxc-exec rejects allowedHosts and blockedHosts as unsupported, - // even when they are empty. The host proxy enforces the L7 allowlist. - let mut value = serde_json::json!({ - "defaultPolicy": network.default_policy.as_str(), - }); - if let Some(proxy) = network.proxy { - value["proxy"] = serde_json::json!({ "localhost": proxy.port() }); - } - value +/// Redacts `process.env` and `process.commandLine` from a wxc-config JSON +/// before it's ever logged or written to a sandbox-readable path (only +/// under `self.debug`, but debug output still isn't a safe place for it). +/// Both can carry host secrets verbatim -- `env` e.g. the shipped +/// `OpenClaw` example config's `OPENCLAW_GATEWAY_TOKEN`, `commandLine` +/// whenever a secret is passed as a literal CLI argument -- and both debug +/// sinks (gateway logs, and for `run_oneshot` a file inside the sandbox's +/// own readwrite path) are places an attacker or an over-broad log +/// retention policy could read from. Everything else debug tooling might +/// need to compare (filesystem grants, network policy, ...) is left intact. +fn redact_env_for_debug(config: &serde_json::Value) -> serde_json::Value { + let mut redacted = config.clone(); + if let Some(env) = redacted.get_mut("process").and_then(|p| p.get_mut("env")) { + let count = env.as_array().map_or(0, Vec::len); + *env = serde_json::json!(format!("")); + } + if let Some(command_line) = redacted + .get_mut("process") + .and_then(|p| p.get_mut("commandLine")) + { + *command_line = serde_json::json!(""); + } + redacted } -fn provision_config_json( - configuration_id: &str, - filesystem: &MxcFilesystem, - network: Option<&MxcNetwork>, -) -> serde_json::Value { - let mut config = serde_json::json!({ - "version": MXC_SCHEMA_VERSION, - "phase": "provision", - "containment": "isolation_session", - "filesystem": { - "readwritePaths": &filesystem.readwrite_paths, - "readonlyPaths": &filesystem.readonly_paths, - }, - "experimental": { - "isolation_session": { - "configurationId": configuration_id, - "provision": {} - } - } - }); - if let Some(network) = network { - config["network"] = network_json(network); +fn network_json(network: &MxcNetwork) -> serde_json::Value { + // MXC 0.8.0-alpha schema uses a directional egress/ingress format. + // "block" default_policy maps to egress.default "deny"; "allow" maps to "allow". + let egress_default = if network.default_policy == "block" { + "deny" + } else { + "allow" + }; + let mut value = if network.proxy.is_some() { + // Use direct loopback egress rather than runtimeConfig.networkProxy proxy + // mode. Proxy mode routes all outbound TCP through processmodel.dll's WFP + // redirect, which can block the authenticated Sandbox Protocol and + // explicit proxy connections to their host loopback listeners. Limit the + // exception to 127.0.0.1/32 rather than the broader 127.0.0.0/8 range. + // PSEC tier is still selected because requires_psec_networking() returns + // true when egress.allow is non-empty (no NetworkIsolationSetAppContainerConfig + // call needed — no elevation required). + // + // Deliberately no `ports` restriction: the authenticated Sandbox + // Protocol listener, generation-scoped supervisor proxy, and dynamic + // forwarding listeners all use independently allocated loopback ports. + serde_json::json!({ + "egress": { + "default": "deny", + "allow": [{"to": [{"cidr": "127.0.0.1/32"}]}] + }, + "ingress": { "default": "allow", "hostLoopback": "allow" }, + }) + } else { + serde_json::json!({ "egress": { "default": egress_default } }) + }; + if network.proxy.is_none() && network.allow_local_network { + value["ingress"] = serde_json::json!({ "default": "allow", "hostLoopback": "allow" }); } - config + value } fn oneshot_config_json( @@ -142,6 +179,7 @@ fn oneshot_config_json( pc: &MxcProcessContainer, process: &MxcProcess, network: Option<&MxcNetwork>, + ui: Option<&MxcUi>, ) -> serde_json::Value { let mut filesystem_json = serde_json::Map::new(); if !filesystem.readwrite_paths.is_empty() { @@ -182,52 +220,18 @@ fn oneshot_config_json( if let Some(network) = network { config["network"] = network_json(network); } + // Root-level ui section required by mxc-fixes-env-vars build. Comes from + // the typed SandboxPolicy via `ui` -- EmbeddedPolicyMapper always + // populates this for process_container (with restrictive defaults -- + // disable=true, Win32k syscall lockdown -- when the policy has no + // explicit `ui:` section), so `None` here only happens in tests that + // bypass the mapper; omit the section entirely rather than guess. + if let Some(ui) = ui { + config["ui"] = ui_json(ui); + } config } -#[cfg(test)] -fn mock_configs() -> &'static Mutex> { - static CONFIGS: OnceLock>> = OnceLock::new(); - CONFIGS.get_or_init(|| Mutex::new(HashMap::new())) -} - -#[cfg(test)] -pub fn mock_recorded_config(id: &str) -> Option { - mock_configs().lock().unwrap().get(id).cloned() -} - -// ── Response envelope ───────────────────────────────────────────────────────── - -#[derive(Debug, Deserialize)] -pub struct ProvisionResult { - #[serde(rename = "sandboxId")] - pub sandbox_id: String, -} - -#[derive(Debug, Deserialize)] -#[serde(untagged)] -pub enum MxcEnvelope { - Ok { - #[allow(dead_code)] - result: serde_json::Value, - }, - Err { - error: MxcErrorBody, - }, -} - -#[derive(Debug, Deserialize)] -pub struct MxcErrorBody { - pub code: String, - pub message: String, -} - -#[derive(Debug, Deserialize)] -pub struct ProvisionEnvelope { - pub result: Option, - pub error: Option, -} - // ── Errors ──────────────────────────────────────────────────────────────────── #[derive(Debug, Error)] @@ -236,55 +240,11 @@ pub enum InvokerError { Spawn(#[from] std::io::Error), #[error("wxc-exec config serialization failed: {0}")] Serialize(#[from] serde_json::Error), - #[error("wxc-exec envelope parse failed (stdout={stdout:?}): {source}")] - Parse { - stdout: String, - source: serde_json::Error, - }, - #[error("wxc-exec process failed with no envelope (exit={exit_code}, stderr={stderr:?})")] - NoEnvelope { exit_code: i32, stderr: String }, - #[error("MXC error [{code}]: {message}")] - Mxc { code: String, message: String }, - /// Exec phase returned a non-zero exit code (the agent's own exit status). - /// Surfaced through the watch stream rather than as a gRPC error. - #[allow(dead_code)] - #[error("wxc-exec exec phase exited with code {0}")] - ExecNonZero(i32), -} - -impl InvokerError { - #[allow(dead_code)] - pub fn to_tonic_status(&self) -> tonic::Status { - match self { - Self::Mxc { code, message } => match code.as_str() { - "malformed_request" | "unsupported_phase" => { - tonic::Status::internal(format!("driver bug: {message}")) - } - "unsupported_containment" - | "not_provisioned" - | "not_started" - | "already_started" - | "already_stopped" => tonic::Status::failed_precondition(message.clone()), - "malformed_id" | "stale_id" => tonic::Status::not_found(message.clone()), - "policy_validation" => tonic::Status::invalid_argument(message.clone()), - "backend_unavailable" => tonic::Status::unavailable(message.clone()), - _ => tonic::Status::internal(message.clone()), - }, - Self::Spawn(e) => tonic::Status::internal(format!("wxc-exec spawn: {e}")), - Self::Serialize(e) => tonic::Status::internal(format!("config serialize: {e}")), - Self::Parse { .. } | Self::NoEnvelope { .. } => { - tonic::Status::internal(self.to_string()) - } - Self::ExecNonZero(code) => { - tonic::Status::internal(format!("agent exited with code {code}")) - } - } - } } // ── Invoker ─────────────────────────────────────────────────────────────────── -/// Wraps `wxc-exec` invocations for the MXC state-aware lifecycle. +/// Wraps the one-shot `wxc-exec` `ProcessContainer` invocation. #[derive(Debug, Clone)] pub struct WxcExecInvoker { exec_path: PathBuf, @@ -306,249 +266,36 @@ impl WxcExecInvoker { self.mock } - /// Test-only constructor that forces mock mode without touching the - /// process-global `OPENSHELL_MXC_MOCK_WXC` env var (avoids races/UB across - /// parallel tests under edition 2024's `unsafe` `set_var`). - #[cfg(test)] - pub(crate) fn mocked(exec_path: impl Into) -> Self { - Self { - exec_path: exec_path.into(), - debug: false, - mock: true, - } - } - - /// Encode `config` as base64 and invoke wxc-exec, returning the parsed envelope. - /// Use this for all **non-exec** phases (provision/start/stop/deprovision). - pub async fn run_phase(&self, config: &serde_json::Value) -> Result<(), InvokerError> { - if self.mock { - // Mock start/stop/deprovision: canned `{"result":{}}` success. - debug!(phase = ?config.get("phase"), "mock wxc-exec phase (no-op success)"); - return Ok(()); - } - let json = serde_json::to_string(config)?; - let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); - - let mut cmd = Command::new(&self.exec_path); - cmd.arg("--config-base64").arg(&b64).arg("--experimental"); - if self.debug { - cmd.arg("--debug"); - } - - debug!(config = %json, "wxc-exec phase"); - let output = cmd.output().await?; - - let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); - let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); - - if !output.status.success() { - if let Ok(MxcEnvelope::Err { error }) = serde_json::from_str::(&stdout) { - return Err(InvokerError::Mxc { - code: error.code, - message: error.message, - }); - } - let code = output.status.code().unwrap_or(-1); - return Err(InvokerError::NoEnvelope { - exit_code: code, - stderr, - }); - } - - // Success — parse envelope to surface any embedded error field. - match serde_json::from_str::(&stdout) { - Ok(MxcEnvelope::Err { error }) => Err(InvokerError::Mxc { - code: error.code, - message: error.message, - }), - Ok(MxcEnvelope::Ok { .. }) => Ok(()), - Err(_) if stdout.trim().is_empty() => { - // Some phases return empty stdout on success. - Ok(()) - } - Err(e) => Err(InvokerError::Parse { stdout, source: e }), - } - } - - /// Run the provision phase and return the `sandboxId` from the response. - pub async fn provision( - &self, - configuration_id: &str, - filesystem: MxcFilesystem, - network: Option, - ) -> Result { - if self.mock { - // Mock provision: mint a synthetic `iso:` id and record the granted - // read-write paths so the mock exec can enforce the policy. - let id = format!("iso:mock-{}", uuid::Uuid::new_v4()); - let grants: Vec = filesystem - .readwrite_paths - .iter() - .map(|p| mock_normalize(p)) - .collect(); - mock_grants().lock().unwrap().insert(id.clone(), grants); - #[cfg(test)] - { - let config = provision_config_json(configuration_id, &filesystem, network.as_ref()); - mock_configs().lock().unwrap().insert(id.clone(), config); - } - debug!(sandbox_id = %id, "mock wxc-exec provision"); - return Ok(id); - } - let config = provision_config_json(configuration_id, &filesystem, network.as_ref()); - - let json = serde_json::to_string(&config)?; - let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); - - let mut cmd = Command::new(&self.exec_path); - cmd.arg("--config-base64").arg(&b64).arg("--experimental"); - if self.debug { - cmd.arg("--debug"); - } - - debug!(config = %json, "wxc-exec provision"); - let output = cmd.output().await?; - - let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); - let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); - - if !output.status.success() { - let code = output.status.code().unwrap_or(-1); - if let Ok(ProvisionEnvelope { - error: Some(error), .. - }) = serde_json::from_str::(&stdout) - { - return Err(InvokerError::Mxc { - code: error.code, - message: error.message, - }); - } - return Err(InvokerError::NoEnvelope { - exit_code: code, - stderr, - }); - } - - let env: ProvisionEnvelope = - serde_json::from_str(&stdout).map_err(|e| InvokerError::Parse { - stdout: stdout.clone(), - source: e, - })?; - - if let Some(err) = env.error { - return Err(InvokerError::Mxc { - code: err.code, - message: err.message, - }); - } - - env.result - .map(|r| r.sandbox_id) - .ok_or_else(|| InvokerError::NoEnvelope { - exit_code: 0, - stderr: "provision result missing sandboxId".to_string(), - }) - } - - /// Run the start phase for an already-provisioned sandbox. - pub async fn start(&self, iso_sandbox_id: &str) -> Result<(), InvokerError> { - let config = serde_json::json!({ - "version": MXC_SCHEMA_VERSION, - "phase": "start", - "sandboxId": iso_sandbox_id, - "experimental": { - "isolation_session": { - "start": {} - } - } - }); - self.run_phase(&config).await - } - - /// Spawn the exec phase (agent command). Returns the child process handle. - /// **Stdout is raw agent output, not a JSON envelope. Exit code == agent exit code.** - pub async fn spawn_exec( - &self, - iso_sandbox_id: &str, - process: MxcProcess, - ) -> Result { - if self.mock { - return Self::mock_spawn_exec(iso_sandbox_id, &process); - } - let config = serde_json::json!({ - "version": MXC_SCHEMA_VERSION, - "phase": "exec", - "sandboxId": iso_sandbox_id, - "process": { - "commandLine": process.command_line, - "cwd": process.cwd, - "env": process.env, - "timeout": process.timeout, - } - }); - - let json = serde_json::to_string(&config)?; - let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); - - let mut cmd = Command::new(&self.exec_path); - cmd.arg("--config-base64") - .arg(&b64) - .arg("--experimental") - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::inherit()) - .stderr(std::process::Stdio::inherit()) - .kill_on_drop(true); - if self.debug { - cmd.arg("--debug"); - } - - debug!(sandbox_id = %iso_sandbox_id, command = %process.command_line, "wxc-exec exec spawn"); - let child = cmd.spawn()?; - Ok(child) - } - - /// Mock exec: simulate `AppContainer` filesystem-policy enforcement. - /// - /// The agent's write target is considered **in-policy** iff the command line - /// references one of the granted read-write paths recorded at mock provision. - fn mock_spawn_exec( - iso_sandbox_id: &str, - process: &MxcProcess, - ) -> Result { - let grants = mock_grants() - .lock() - .unwrap() - .get(iso_sandbox_id) - .cloned() - .unwrap_or_default(); - Self::mock_spawn_with_grants(process, &grants) - } - - /// Shared mock enforcement used by both the `isolation_session` exec phase - /// and the one-shot `processContainer` path. - /// - /// In-policy → run the real agent command (so the positive-proof artifact, - /// e.g. `hello.txt`, actually appears on the host shared folder). Out-of-policy - /// → refuse with an access-denied message on stderr and a non-zero exit, - /// mirroring how the `AppContainer` denies the write on the demo box. + /// Launch the real boundary runtime without OS isolation for wiring tests. + /// Filesystem denial must be tested with real MXC, not this mock. fn mock_spawn_with_grants( process: &MxcProcess, grants: &[String], ) -> Result { let cmd_norm = mock_normalize(&process.command_line); + // The RFC 0012 boundary is launched with its granted bootstrap directory. + // This check concerns bootstrap wiring, never workload authorization. let in_policy = grants.iter().any(|g| !g.is_empty() && cmd_norm.contains(g)); - let mut cmd = Command::new("cmd"); + // Spawn the encoded executable directly: a cmd.exe intermediary would + // survive only as a parent handle while kill_on_drop orphaned its child. + let (program, arguments) = process + .command_line + .strip_prefix('"') + .map_or_else( + || process.command_line.split_once(char::is_whitespace), + |quoted| quoted.split_once('"'), + ) + .unwrap_or((&process.command_line, "")); + let mut cmd = Command::new(if in_policy { program } else { "cmd" }); cmd.stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::inherit()) .stderr(std::process::Stdio::inherit()) .kill_on_drop(true); if in_policy { debug!(command = %process.command_line, "mock exec: in-policy, running agent"); - // `command_line` is already encoded with Windows quoting rules. - // Pass it raw so this mock matches wxc-exec/CreateProcess instead - // of asking Rust to quote the entire command as one cmd.exe argv. - cmd.raw_arg(format!("/d /s /c \"{}\"", process.command_line)); + // Retain the already-encoded Windows argv without another quoting pass. + cmd.raw_arg(arguments.trim_start()); } else { debug!(command = %process.command_line, "mock exec: OUT-OF-POLICY, denying"); cmd.arg("/c").arg( @@ -561,10 +308,9 @@ impl WxcExecInvoker { /// Build a **one-shot** `processContainer` config (no `phase`) and spawn it. /// - /// Unlike the `isolation_session` lifecycle (provision → start → exec → - /// stop → deprovision), `processContainer` is a single ephemeral - /// `AppContainer`: one `wxc-exec` invocation creates the container, runs the - /// one process, and tears down when it exits. The `AppContainer` is genuinely + /// `processContainer` is a single ephemeral `AppContainer`: one `wxc-exec` + /// invocation creates the container, runs the sandbox runtime, and tears it + /// down when that runtime exits. The `AppContainer` is genuinely /// default-deny, so a write to any ungranted path is denied by the OS. /// /// **Stdout is raw agent output; the exit code is the agent's own exit code.** @@ -575,76 +321,68 @@ impl WxcExecInvoker { pc: MxcProcessContainer, process: MxcProcess, network: Option, + ui: Option, ) -> Result { - let config = - oneshot_config_json(container_id, &filesystem, &pc, &process, network.as_ref()); + let config = oneshot_config_json( + container_id, + &filesystem, + &pc, + &process, + network.as_ref(), + ui.as_ref(), + ); if self.mock { let grants: Vec = filesystem .readwrite_paths .iter() .map(|p| mock_normalize(p)) .collect(); - #[cfg(test)] - mock_configs() - .lock() - .unwrap() - .insert(container_id.to_owned(), config); return Self::mock_spawn_with_grants(&process, &grants); } let json = serde_json::to_string(&config)?; + if self.debug { + // Redacted before either sink: the readwrite path is inside the + // sandbox itself (readable by whatever untrusted code runs + // there), and gateway logs may have broader retention/access + // than the secrets in `process.env` (e.g. OPENCLAW_GATEWAY_TOKEN + // in the shipped OpenClaw example config) should get. + let redacted = redact_env_for_debug(&config); + let redacted_json = serde_json::to_string(&redacted).unwrap_or_else(|_| json.clone()); + // Dump into the first readwrite path for comparison. + if let Some(rw) = config + .get("filesystem") + .and_then(|f| f.get("readwritePaths")) + .and_then(|a| a.as_array()) + .and_then(|a| a.first()) + .and_then(|v| v.as_str()) + { + let _ = std::fs::write( + std::path::Path::new(rw).join("wxc-exec-config-debug.json"), + &redacted_json, + ); + } + let pretty = + serde_json::to_string_pretty(&redacted).unwrap_or_else(|_| redacted_json.clone()); + info!(container_id = %container_id, "generated wxc-config:\n{pretty}"); + } let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); let mut cmd = Command::new(&self.exec_path); cmd.arg("--config-base64") .arg(&b64) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::inherit()) - .stderr(std::process::Stdio::inherit()) - .kill_on_drop(true); + // Retain child output so the gateway can surface sandbox logs. + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); if self.debug { cmd.arg("--debug"); } - debug!(container_id = %container_id, command = %process.command_line, "wxc-exec one-shot processContainer spawn"); + info!(container_id = %container_id, "wxc-exec one-shot processContainer spawn"); let child = cmd.spawn()?; Ok(child) } - - /// Run the stop phase. - /// - /// `stop`/`deprovision` are **unit** variants in the wxc-exec schema: they - /// must serialize as `null`, not `{}`. Empirical (build 26300.8553, - /// wxc-exec 2026-06-10): `"stop": {}` is rejected with `malformed_request` - /// ("invalid type: map, expected unit"); `provision`/`start` accept maps. - pub async fn stop(&self, iso_sandbox_id: &str) -> Result<(), InvokerError> { - let config = serde_json::json!({ - "version": MXC_SCHEMA_VERSION, - "phase": "stop", - "sandboxId": iso_sandbox_id, - "experimental": { - "isolation_session": { - "stop": null - } - } - }); - self.run_phase(&config).await - } - - /// Run the deprovision phase (unit variant — see [`Self::stop`]). - pub async fn deprovision(&self, iso_sandbox_id: &str) -> Result<(), InvokerError> { - let config = serde_json::json!({ - "version": MXC_SCHEMA_VERSION, - "phase": "deprovision", - "sandboxId": iso_sandbox_id, - "experimental": { - "isolation_session": { - "deprovision": null - } - } - }); - self.run_phase(&config).await - } } // ── Tests (pure serde — compile and run cross-platform) ────────────────────── @@ -653,65 +391,6 @@ impl WxcExecInvoker { mod tests { use super::*; - #[test] - fn provision_envelope_parse_success() { - let json = r#"{"result":{"sandboxId":"iso:wxc-abc123","metadata":{}}}"#; - let env: ProvisionEnvelope = serde_json::from_str(json).unwrap(); - assert_eq!(env.result.unwrap().sandbox_id, "iso:wxc-abc123"); - assert!(env.error.is_none()); - } - - #[test] - fn provision_envelope_parse_error() { - let json = - r#"{"error":{"code":"backend_unavailable","message":"IsoSessionApp.dll missing"}}"#; - let env: ProvisionEnvelope = serde_json::from_str(json).unwrap(); - assert!(env.result.is_none()); - let err = env.error.unwrap(); - assert_eq!(err.code, "backend_unavailable"); - } - - #[test] - fn mxc_envelope_success_variant() { - let json = r#"{"result":{}}"#; - let env: MxcEnvelope = serde_json::from_str(json).unwrap(); - assert!(matches!(env, MxcEnvelope::Ok { .. })); - } - - #[test] - fn mxc_envelope_error_variant() { - let json = r#"{"error":{"code":"not_provisioned","message":"call provision first"}}"#; - let env: MxcEnvelope = serde_json::from_str(json).unwrap(); - assert!(matches!(env, MxcEnvelope::Err { .. })); - } - - #[test] - fn provision_config_json_shape() { - // Verify the JSON we send wxc-exec has the expected shape. - let config = serde_json::json!({ - "version": MXC_SCHEMA_VERSION, - "phase": "provision", - "containment": "isolation_session", - "filesystem": { - "readwritePaths": ["C:\\work\\demo"], - "readonlyPaths": [], - }, - "experimental": { - "isolation_session": { - "configurationId": DEFAULT_CONFIGURATION_ID, - "provision": {} - } - } - }); - assert_eq!(config["phase"], "provision"); - assert_eq!(config["containment"], "isolation_session"); - assert_eq!( - config["experimental"]["isolation_session"]["configurationId"], - "composable" - ); - assert_eq!(config["filesystem"]["readwritePaths"][0], "C:\\work\\demo"); - } - #[test] fn oneshot_processcontainer_config_json_shape() { // Mirror the JSON `run_oneshot` builds for the one-shot processContainer @@ -745,48 +424,25 @@ mod tests { } #[test] - fn provision_config_json_includes_network_proxy_when_supplied() { - let filesystem = MxcFilesystem { - readwrite_paths: vec!["C:\\work\\demo".into()], - readonly_paths: Vec::new(), - denied_paths: Vec::new(), - }; - let network = MxcNetwork { - default_policy: "block".into(), - proxy: Some("127.0.0.1:18080".parse().unwrap()), - }; - let config = provision_config_json(DEFAULT_CONFIGURATION_ID, &filesystem, Some(&network)); - - assert_eq!(config["network"]["defaultPolicy"], "block"); - assert!(config["network"].get("allowedHosts").is_none()); - assert!(config["network"].get("blockedHosts").is_none()); - // MXC 0.6.0-alpha accepts only {"proxy": {"localhost": N}}. - assert_eq!(config["network"]["proxy"]["localhost"], 18080); - assert!( - config["network"]["proxy"].get("host").is_none(), - "proxy must not contain 'host' key" - ); - assert!( - config["network"]["proxy"].get("port").is_none(), - "proxy must not contain 'port' key" - ); - } - - #[test] - fn network_json_emits_localhost_port_shape() { - // MXC 0.6.0-alpha rejects {"host":...,"port":...} and accepts only - // {"proxy": {"localhost": N}} — verified against the real binary via - // --dry-run. This test pins the exact emitted JSON shape. + fn network_json_emits_directional_format() { + // MXC 0.8.0-alpha: egress/ingress replaces the legacy + // defaultPolicy / allowedHosts / proxy.localhost shape. let network = MxcNetwork { default_policy: "block".into(), proxy: Some("127.0.0.1:18080".parse().unwrap()), + allow_local_network: false, }; let value = network_json(&network); - assert!(value.get("allowedHosts").is_none()); - assert!(value.get("blockedHosts").is_none()); - assert_eq!(value["proxy"]["localhost"], 18080); - assert!(value["proxy"].get("host").is_none()); - assert!(value["proxy"].get("port").is_none()); + // Loopback-allow mode: egress.default="deny" with 127.0.0.1/32 allow rule. + // Allows the sandbox to reach its authenticated host-side listeners + // without enabling direct Internet access. + assert_eq!(value["egress"]["default"], "deny"); + assert_eq!(value["egress"]["allow"][0]["to"][0]["cidr"], "127.0.0.1/32"); + // ingress.hostLoopback="allow" grants networkLoopback PSEC capability. + assert_eq!(value["ingress"]["default"], "allow"); + assert_eq!(value["ingress"]["hostLoopback"], "allow"); + assert!(value.get("proxy").is_none()); + assert!(value.get("defaultPolicy").is_none()); } #[test] @@ -803,61 +459,31 @@ mod tests { env: Vec::new(), timeout: 0, }; - let config = oneshot_config_json("sb-1", &filesystem, &pc, &process, None); + let config = oneshot_config_json("sb-1", &filesystem, &pc, &process, None, None); assert!(config.get("network").is_none()); + assert!(config.get("ui").is_none()); } #[test] - fn stop_and_deprovision_serialize_as_unit_variants() { - // Pins the empirical schema contract (test box, build 26300.8553): - // stop/deprovision are unit variants and must be `null`; `{}` is - // rejected with malformed_request "invalid type: map, expected unit". - for phase in ["stop", "deprovision"] { - let config = serde_json::json!({ - "version": MXC_SCHEMA_VERSION, - "phase": phase, - "sandboxId": "iso:wxc-test", - "experimental": { - "isolation_session": { - phase: null - } - } - }); - assert!( - config["experimental"]["isolation_session"][phase].is_null(), - "{phase} must serialize as null (unit variant)" - ); - } - } - - #[test] - fn invoker_error_maps_backend_unavailable_to_unavailable() { - let err = InvokerError::Mxc { - code: "backend_unavailable".into(), - message: "missing DLL".into(), + fn oneshot_config_json_emits_typed_ui_policy() { + let filesystem = MxcFilesystem::default(); + let pc = MxcProcessContainer::default(); + let process = MxcProcess { + command_line: "cmd /c exit 0".into(), + cwd: "C:\\work\\demo".into(), + env: Vec::new(), + timeout: 0, }; - let status = err.to_tonic_status(); - assert_eq!(status.code(), tonic::Code::Unavailable); - } - - #[test] - fn invoker_error_maps_policy_validation_to_invalid_argument() { - let err = InvokerError::Mxc { - code: "policy_validation".into(), - message: "path denied".into(), + let ui = MxcUi { + disable: false, + clipboard: MxcClipboardAccess::Write, + injection: true, }; - let status = err.to_tonic_status(); - assert_eq!(status.code(), tonic::Code::InvalidArgument); - } + let config = oneshot_config_json("sb-ui", &filesystem, &pc, &process, None, Some(&ui)); - #[test] - fn invoker_error_maps_stale_id_to_not_found() { - let err = InvokerError::Mxc { - code: "stale_id".into(), - message: "session expired".into(), - }; - let status = err.to_tonic_status(); - assert_eq!(status.code(), tonic::Code::NotFound); + assert_eq!(config["ui"]["disable"], false); + assert_eq!(config["ui"]["clipboard"], "write"); + assert_eq!(config["ui"]["injection"], true); } } diff --git a/crates/openshell-driver-mxc/src/policy.rs b/crates/openshell-driver-mxc/src/policy.rs index 30a0ca56da..753785e3d2 100644 --- a/crates/openshell-driver-mxc/src/policy.rs +++ b/crates/openshell-driver-mxc/src/policy.rs @@ -19,6 +19,7 @@ use std::net::SocketAddr; +use crate::mxc::{MxcClipboardAccess, MxcUi}; use openshell_core::proto::SandboxPolicy; use thiserror::Error; @@ -38,6 +39,9 @@ pub struct MappedConfig { /// Loopback address MXC redirects sandbox egress to. `None` when governed /// egress is disabled. pub proxy_addr: Option, + /// Top-level MXC UI policy for process containers. Isolation sessions keep + /// this absent because current MXC rejects the section on presence. + pub ui: Option, } /// Context passed to the mapper alongside the policy. @@ -49,6 +53,8 @@ pub struct MapCtx { /// Pattern-C governed-egress redirect address. When set, the embedded /// mapper uses `split_policy`; otherwise it uses the coarse MXC map. pub egress: Option, + /// MXC containment backend selected by the live driver. + pub containment: String, } /// A policy rule that the active mapper cannot enforce. @@ -109,6 +115,39 @@ fn extract_paths(config: &serde_json::Value, key: &str) -> Vec { .unwrap_or_default() } +fn extract_ui(config: &serde_json::Value) -> Result, MapError> { + let Some(ui) = config.get("ui") else { + return Ok(None); + }; + let disable = ui["disable"] + .as_bool() + .ok_or_else(|| MapError::Internal("mapped MXC ui.disable is not a boolean".into()))?; + let clipboard = match ui["clipboard"].as_str() { + Some("none") => MxcClipboardAccess::None, + Some("read") => MxcClipboardAccess::Read, + Some("write") => MxcClipboardAccess::Write, + Some("all") => MxcClipboardAccess::All, + Some(value) => { + return Err(MapError::Internal(format!( + "mapped MXC ui.clipboard has unknown value '{value}'" + ))); + } + None => { + return Err(MapError::Internal( + "mapped MXC ui.clipboard is not a string".into(), + )); + } + }; + let injection = ui["injection"] + .as_bool() + .ok_or_else(|| MapError::Internal("mapped MXC ui.injection is not a boolean".into()))?; + Ok(Some(MxcUi { + disable, + clipboard, + injection, + })) +} + impl PolicyMapper for EmbeddedPolicyMapper { fn map(&self, policy: Option<&SandboxPolicy>, ctx: &MapCtx) -> Result { let policy = policy.ok_or_else(|| { @@ -122,7 +161,7 @@ impl PolicyMapper for EmbeddedPolicyMapper { // Pattern C: MXC handles filesystem + a proxy redirect, while the // host CONNECT proxy receives the network-only trimmed policy. let opts = crate::policy_map::MxcMappingOptions { - containment: "processcontainer".to_owned(), + containment: ctx.containment.clone(), container_id: ctx.sandbox_id.clone(), proxy_redirect: Some(addr), ..Default::default() @@ -144,7 +183,7 @@ impl PolicyMapper for EmbeddedPolicyMapper { // yields an `error` loss for any host allowlist, which rejects // network policy below. let opts = crate::policy_map::MxcMappingOptions { - containment: "isolation_session".to_owned(), + containment: ctx.containment.clone(), container_id: ctx.sandbox_id.clone(), ..Default::default() }; @@ -176,12 +215,14 @@ impl PolicyMapper for EmbeddedPolicyMapper { .iter() .map(|p| normalize_path(p)) .collect(); + let ui = extract_ui(&config)?; Ok(MappedConfig { readwrite_paths: readwrite, readonly_paths: readonly, trimmed_policy, proxy_addr, + ui, }) } } @@ -197,6 +238,15 @@ mod tests { MapCtx { sandbox_id: "sb-test".into(), egress: None, + containment: "isolation_session".into(), + } + } + + fn processcontainer_ctx() -> MapCtx { + MapCtx { + sandbox_id: "sb-test".into(), + egress: None, + containment: "processcontainer".into(), } } @@ -255,6 +305,78 @@ mod tests { assert!(matches!(err, MapError::Unsupported(_))); } + #[test] + fn embedded_rejects_explicit_ui_on_isolation_session() { + use openshell_core::proto::UiPolicy; + + let mapper = EmbeddedPolicyMapper; + let policy = SandboxPolicy { + ui: Some(UiPolicy::default()), + ..Default::default() + }; + let err = mapper.map(Some(&policy), &demo_ctx()).unwrap_err(); + match err { + MapError::Unsupported(items) => { + assert_eq!(items.len(), 1); + assert_eq!(items[0].rule_kind, "ui"); + } + MapError::Internal(message) => { + panic!("expected unsupported UI, got internal error: {message}") + } + } + } + + #[test] + fn embedded_carries_typed_ui_for_processcontainer() { + use openshell_core::proto::{UiClipboardAccess, UiPolicy}; + + let mapper = EmbeddedPolicyMapper; + let policy = SandboxPolicy { + ui: Some(UiPolicy { + allow_graphical_ui: true, + clipboard: UiClipboardAccess::Read as i32, + allow_input_injection: true, + }), + ..Default::default() + }; + let result = mapper + .map(Some(&policy), &processcontainer_ctx()) + .expect("processContainer UI maps"); + assert_eq!( + result.ui, + Some(MxcUi { + disable: false, + clipboard: MxcClipboardAccess::Read, + injection: true, + }) + ); + } + + #[test] + fn embedded_rejects_ui_grants_suppressed_by_disable() { + use openshell_core::proto::{UiClipboardAccess, UiPolicy}; + + for ui in [ + UiPolicy { + clipboard: UiClipboardAccess::Read as i32, + ..Default::default() + }, + UiPolicy { + allow_input_injection: true, + ..Default::default() + }, + ] { + let policy = SandboxPolicy { + ui: Some(ui), + ..Default::default() + }; + let error = EmbeddedPolicyMapper + .map(Some(&policy), &processcontainer_ctx()) + .expect_err("MXC cannot enforce UI grants while UI is disabled"); + assert!(matches!(error, MapError::Unsupported(_))); + } + } + #[test] fn embedded_split_normalizes_paths_and_returns_proxy_handoff() { use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule}; @@ -281,6 +403,7 @@ mod tests { sandbox_id: "sb-egress".into(), egress: Some(proxy_addr), + containment: "processcontainer".into(), }; let config = mapper.map(Some(&policy), &ctx).unwrap(); @@ -294,7 +417,7 @@ mod tests { } #[test] - fn embedded_rejects_network_middleware_on_egress_proxy() { + fn embedded_preserves_network_middleware_for_supervisor() { let mapper = EmbeddedPolicyMapper; let mut policy = fs_policy(&["C:/work/demo"], &[]); policy.network_middlewares.insert( @@ -313,14 +436,11 @@ mod tests { let ctx = MapCtx { sandbox_id: "sb-egress-middleware".into(), egress: Some("127.0.0.1:18080".parse().unwrap()), + containment: "processcontainer".into(), }; - let error = mapper.map(Some(&policy), &ctx).unwrap_err(); - let MapError::Unsupported(loss) = error else { - panic!("expected unsupported middleware error"); - }; - assert_eq!(loss.len(), 1); - assert_eq!(loss[0].rule_kind, "network_middlewares"); - assert!(loss[0].detail.contains("middleware service registry")); + let result = mapper.map(Some(&policy), &ctx).unwrap(); + let trimmed = result.trimmed_policy.expect("trimmed proxy policy"); + assert_eq!(trimmed.network_middlewares, policy.network_middlewares); } } diff --git a/crates/openshell-driver-mxc/src/policy_map/loss.rs b/crates/openshell-driver-mxc/src/policy_map/loss.rs index cf366b0b9d..6af3b9a32b 100644 --- a/crates/openshell-driver-mxc/src/policy_map/loss.rs +++ b/crates/openshell-driver-mxc/src/policy_map/loss.rs @@ -25,7 +25,7 @@ pub struct LossItem { /// MXC capabilities that have no `OpenShell` *policy* equivalent. Surfaced in the /// loss report so reviewers understand the mapping is not symmetric. pub const OPEN_SHELL_SUPERSET_GAPS: &[&str] = &[ - "MXC UI policy has no OpenShell policy equivalent: ui.disable, ui.clipboard, and ui.injection.", + "MXC processContainer UI refinements have no portable OpenShell policy equivalent: isolation, desktopSystemControl, systemSettings, and ime.", "MXC lifecycle fields have no OpenShell policy equivalent: destroyOnExit, preservePolicy, phase, and sandboxId.", "MXC backend selection and backend-specific blocks are outside OpenShell policy YAML.", "MXC process command, cwd, env, and timeout are runtime config fields, not OpenShell policy fields.", diff --git a/crates/openshell-driver-mxc/src/policy_map/map.rs b/crates/openshell-driver-mxc/src/policy_map/map.rs index c1b3af94fa..14d8fc9cb5 100644 --- a/crates/openshell-driver-mxc/src/policy_map/map.rs +++ b/crates/openshell-driver-mxc/src/policy_map/map.rs @@ -11,7 +11,9 @@ use std::net::SocketAddr; -use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule, SandboxPolicy}; +use openshell_core::proto::{ + NetworkEndpoint, NetworkPolicyRule, SandboxPolicy, UiClipboardAccess, UiPolicy, +}; use serde_json::{Value, json}; use super::config::{ @@ -19,12 +21,14 @@ use super::config::{ add_backend_specific_config, default_enforcement_mode, filesystem_default_deny_message, }; use super::loss::{LossItem, add_loss}; +use crate::mxc::MXC_SCHEMA_VERSION; /// Options controlling the generated MXC config. Fields not relevant to the /// coarse map (e.g. `proxy_redirect`) are reserved for the governed-egress split. #[derive(Clone, Debug)] pub struct MxcMappingOptions { - /// MXC schema version written into `version`. + /// MXC schema version written into coarse-map output. The governed-egress + /// split uses the driver's MXC 0.8 schema. pub mxc_version: String, /// MXC containment backend. pub containment: String, @@ -68,16 +72,15 @@ pub struct MxcMappingResult { pub loss: Vec, } -/// Result of the governed-egress split: the MXC config carries filesystem grants and a -/// proxy redirect; the full network policy is returned unchanged for the -/// `OpenShell` CONNECT proxy to enforce. +/// Result of the lossless split: the MXC config carries filesystem grants and +/// loopback-only network access; the full network policy is returned unchanged +/// for the `OpenShell` CONNECT proxy to enforce. #[derive(Clone, Debug)] pub struct SplitPolicyResult { - /// MXC `ContainerConfig` with filesystem grants and `network.proxy` redirect. + /// MXC `ContainerConfig` with filesystem grants and loopback-only egress. /// - /// Direct egress is blocked at the MXC layer. Unsupported host-list fields - /// are omitted; all outbound connections flow through the proxy, which - /// enforces the full `OpenShell` network policy. + /// Direct Internet egress is denied at the MXC layer. Proxy-aware clients + /// use the host proxy, which enforces the full `OpenShell` network policy. pub mxc_config: Value, /// Full `OpenShell` network policy preserved verbatim for the host CONNECT /// proxy. Only `network_policies` is populated; the proxy does not enforce @@ -98,13 +101,13 @@ pub fn map_to_mxc(policy: &SandboxPolicy, opts: &MxcMappingOptions) -> MxcMappin /// Governed-egress split: map filesystem + containment to MXC, delegate network to the /// `OpenShell` CONNECT proxy. /// -/// The returned [`SplitPolicyResult::mxc_config`] sets `network.proxy` to -/// `opts.proxy_redirect` and omits unsupported host-list fields. Direct egress -/// is blocked at the MXC layer and all outbound connections flow through the -/// proxy. [`SplitPolicyResult::proxy_policy`] carries the original -/// `network_policies` verbatim; no binary-scope, port, protocol, or wildcard -/// loss items are generated for those rules. Network middleware is rejected -/// until the host proxy can receive the gateway middleware service registry. +/// The returned [`SplitPolicyResult::mxc_config`] allows only `127.0.0.1/32` +/// egress and denies direct Internet access at the MXC layer. The driver injects +/// `HTTP_PROXY`/`HTTPS_PROXY` for proxy-aware clients. +/// [`SplitPolicyResult::proxy_policy`] carries the original network policy +/// verbatim. The RFC 0012 host supervisor receives the complete policy and the +/// gateway middleware service registry through its ordinary session, so the +/// MXC outer-fence mapping does not reject middleware configuration. /// /// Returns `None` if `opts.proxy_redirect` is not set. Use [`map_to_mxc`] /// for the standalone coarse path when no proxy is in the loop. @@ -151,11 +154,11 @@ fn build_split_mxc_config( "containment", "error", &format!( - "`network.proxy` is not supported on `{}`; governed egress requires processcontainer until MXC M1 lands.", + "MXC loopback-only proxy access is not supported on `{}`; governed egress requires processcontainer.", opts.containment ), "governed egress proxy redirect", - "The generated MXC config omits network.proxy for this backend.", + "The generated MXC config cannot enable loopback-only proxy access for this backend.", ); } if !policy.network_policies.is_empty() { @@ -171,48 +174,34 @@ fn build_split_mxc_config( "The host proxy receives the trimmed policy and enforces network rules.", ); } - if !policy.network_middlewares.is_empty() { - add_loss( - items, - "network_middlewares", - "error", - &format!( - "{} network middleware config(s) cannot be enforced because the MXC host proxy is not connected to the gateway middleware service registry.", - policy.network_middlewares.len() - ), - "network egress middleware", - "The MXC sandbox is rejected before launch instead of bypassing fail-open middleware or failing unrelated allowed traffic.", - ); - } - - // Direct egress is blocked; all outbound flows through the OpenShell proxy. - // Released wxc-exec rejects allowedHosts and blockedHosts as unsupported, - // even when empty, so the proxy path omits both fields. - // - // MXC 0.6.0-alpha schema accepts ONLY {"proxy": {"localhost": }}. - // {"host": ..., "port": ...} and every other shape is rejected — verified - // empirically against the real wxc-exec 0.6.0-alpha binary via --dry-run. + // Direct Internet egress is denied. Proxy-aware clients can reach only the + // OpenShell proxy (and other host loopback listeners) through 127.0.0.1. if proxy_supported && proxy_addr.ip() != std::net::IpAddr::from([127, 0, 0, 1]) { add_loss( items, - "network.proxy", + "proxy_redirect", "error", &format!( - "MXC schema 0.6.0-alpha can only express a localhost port \ - ({{\"localhost\": N}}); non-127.0.0.1 redirect address \ - {proxy_addr} is not representable." + "MXC governed egress requires the unpackaged OpenShell host \ + proxy to use 127.0.0.1; redirect address {proxy_addr} is not supported." ), "per-sandbox egress attribution", - "The redirect cannot be emitted; use a 127.0.0.1:PORT address.", + "The proxy environment cannot be emitted safely; use a 127.0.0.1:PORT address.", ); } - let mut network = json!({ "defaultPolicy": "block" }); - if proxy_supported && proxy_addr.ip() == std::net::IpAddr::from([127, 0, 0, 1]) { - network["proxy"] = json!({ "localhost": proxy_addr.port() }); - } + let network = json!({ + "egress": { + "default": "deny", + "allow": [{"to": [{"cidr": "127.0.0.1/32"}]}], + }, + "ingress": { + "default": "allow", + "hostLoopback": "allow", + }, + }); let mut config = json!({ - "version": opts.mxc_version, + "version": MXC_SCHEMA_VERSION, "containerId": opts.container_id, "containment": opts.containment, "lifecycle": { @@ -222,12 +211,10 @@ fn build_split_mxc_config( "process": process, "filesystem": filesystem, "network": network, - "ui": { - "disable": true, - "clipboard": "none", - "injection": false, - }, }); + if let Some(ui) = map_ui(policy.ui.as_ref(), &opts.containment, items) { + config["ui"] = ui; + } // No network hosts, so backend-specific network blocks (processContainer // internetClient, etc.) are not added — correct for the proxy path. @@ -275,18 +262,110 @@ fn build_mxc_config( "process": process, "filesystem": filesystem, "network": network, - "ui": { - "disable": true, - "clipboard": "none", - "injection": false, - }, }); + if let Some(ui) = map_ui(policy.ui.as_ref(), &opts.containment, items) { + config["ui"] = ui; + } add_backend_specific_config(&mut config, &opts.containment, &allowed_hosts, items); add_static_policy_loss(policy, opts, items); config } +fn map_ui(ui: Option<&UiPolicy>, containment: &str, items: &mut Vec) -> Option { + let restrictive = || { + json!({ + "disable": true, + "clipboard": "none", + "injection": false, + }) + }; + + match containment { + "processcontainer" | "process" => { + let Some(ui) = ui else { + // Preserve the mapper's existing deny posture for policies + // authored before the optional OpenShell UI section existed. + return Some(restrictive()); + }; + let clipboard = match UiClipboardAccess::try_from(ui.clipboard) { + Ok(UiClipboardAccess::Unspecified | UiClipboardAccess::None) => "none", + Ok(UiClipboardAccess::Read) => "read", + Ok(UiClipboardAccess::Write) => "write", + Ok(UiClipboardAccess::All) => "all", + Err(_) => { + add_loss( + items, + "ui.clipboard", + "error", + &format!( + "OpenShell UI clipboard policy has unknown enum value {}.", + ui.clipboard + ), + "directional clipboard access", + "MXC receives the restrictive clipboard=none fallback; sandbox creation is rejected.", + ); + "none" + } + }; + let graphical_ui_disabled = !ui.allow_graphical_ui; + if graphical_ui_disabled && clipboard != "none" { + add_loss( + items, + "ui.clipboard", + "error", + "MXC ignores clipboard grants when ui.disable is true.", + "directional clipboard access without graphical UI", + "MXC receives clipboard=none and sandbox creation is rejected; set allow_graphical_ui=true to request clipboard access.", + ); + } + if graphical_ui_disabled && ui.allow_input_injection { + add_loss( + items, + "ui.allow_input_injection", + "error", + "MXC ignores input-injection grants when ui.disable is true.", + "input injection without graphical UI", + "MXC receives injection=false and sandbox creation is rejected; set allow_graphical_ui=true to request input injection.", + ); + } + Some(json!({ + "disable": graphical_ui_disabled, + "clipboard": if graphical_ui_disabled { "none" } else { clipboard }, + "injection": !graphical_ui_disabled && ui.allow_input_injection, + })) + } + "isolation_session" => { + if ui.is_some() { + add_loss( + items, + "ui", + "error", + "MXC isolation_session rejects every explicitly supplied top-level UI policy, including an empty or deny-only policy.", + "OpenShell UI policy", + "The UI block is omitted and sandbox creation is rejected before wxc-exec is invoked.", + ); + } + None + } + _ => { + if ui.is_some() { + add_loss( + items, + "ui", + "error", + &format!( + "OpenShell UI policy enforcement is not supported by the MXC `{containment}` mapping target." + ), + "OpenShell UI policy", + "The generated config remains at the mapper's restrictive UI defaults and the caller must reject the mapping.", + ); + } + Some(restrictive()) + } + } +} + fn map_filesystem( policy: &SandboxPolicy, opts: &MxcMappingOptions, diff --git a/crates/openshell-driver-mxc/src/policy_map/mod.rs b/crates/openshell-driver-mxc/src/policy_map/mod.rs index 156cd8ea7a..670e31a617 100644 --- a/crates/openshell-driver-mxc/src/policy_map/mod.rs +++ b/crates/openshell-driver-mxc/src/policy_map/mod.rs @@ -18,8 +18,8 @@ //! and anything MXC cannot express (ports, protocol, L7 rules, binary scope) //! is recorded in the loss report. Use this when MXC enforces network on its //! own, with no `OpenShell` proxy in the loop. -//! - [`split_policy`] — the governed-egress split for the Windows MXC compute -//! driver: MXC handles filesystem + containment + a `network.proxy` redirect, +//! - [`split_policy`] — the *lossless* split for the Windows MXC compute +//! driver: MXC handles filesystem + containment + loopback-only egress, //! while the full `OpenShell` network policy is preserved in a trimmed policy //! enforced by the host CONNECT proxy. //! diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs b/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs index d8d6a37904..2c4bf4f0a9 100644 --- a/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs +++ b/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs @@ -210,34 +210,27 @@ fn all_example_policies_split_with_expected_invariants() { assert!(str_list(&cfg["filesystem"]["readonlyPaths"]).is_empty()); } - assert_eq!(cfg["network"]["defaultPolicy"], "block"); - assert!(str_list(&cfg["network"]["allowedHosts"]).is_empty()); - // MXC 0.6.0-alpha accepts only {"proxy": {"localhost": N}}. - assert_eq!(cfg["network"]["proxy"]["localhost"], 18080); - assert!(cfg["network"]["proxy"].get("host").is_none()); - assert!(cfg["network"]["proxy"].get("port").is_none()); - let errors: Vec<_> = result - .loss - .iter() - .filter(|item| item.severity == "error") - .collect(); - if policy.network_middlewares.is_empty() { - assert!( - errors.is_empty(), - "processcontainer split must not emit error losses for {}: {:?}", - path.display(), - result.loss - ); - } else { - assert_eq!( - errors.len(), - 1, - "middleware policy must have one fail-closed loss for {}: {:?}", - path.display(), - result.loss - ); - assert_eq!(errors[0].path, "network_middlewares"); - } + assert_eq!(cfg["version"], "0.8.0-alpha"); + assert_eq!(cfg["network"]["egress"]["default"], "deny"); + assert_eq!( + cfg["network"]["egress"]["allow"][0]["to"][0]["cidr"], + "127.0.0.1/32" + ); + assert_eq!(cfg["network"]["ingress"]["hostLoopback"], "allow"); + assert!(cfg.get("runtimeConfig").is_none()); + assert!(cfg["network"].get("proxy").is_none()); + assert!( + !result.loss.iter().any(|item| item.severity == "error"), + "processcontainer split must not emit error losses for {}: {:?}", + path.display(), + result.loss + ); + assert_eq!( + result.proxy_policy.network_middlewares, + policy.network_middlewares, + "supervisor handoff must preserve middleware for {}", + path.display() + ); } } @@ -295,29 +288,24 @@ fn split_policy_routes_network_to_proxy() { let result = split_policy(&policy, &opts).expect("split_policy returns Some when addr is set"); let cfg = &result.mxc_config; - // Proxy redirect is emitted. 127.0.0.2 is not the loopback 127.0.0.1 so - // the mapper records an error loss and omits the proxy block entirely. - // (MXC 0.6.0-alpha can only encode {"localhost": N}; non-127.0.0.1 is - // not representable.) + // 127.0.0.2 is not the supported 127.0.0.1 host-proxy address, so the + // mapper records an error loss and keeps the MXC config fail-closed. assert!( - cfg["network"].get("proxy").is_none() || cfg["network"]["proxy"].is_null(), - "non-127.0.0.1 redirect must NOT produce a proxy block: {:?}", - cfg["network"].get("proxy") + cfg.get("runtimeConfig").is_none() || cfg["runtimeConfig"].is_null(), + "non-127.0.0.1 redirect must NOT produce runtimeConfig: {:?}", + cfg.get("runtimeConfig") ); let has_proxy_loss = result .loss .iter() - .any(|i| i.path == "network.proxy" && i.severity == "error"); + .any(|i| i.path == "proxy_redirect" && i.severity == "error"); assert!( has_proxy_loss, "non-127.0.0.1 redirect must produce an error loss item" ); - // Direct egress is blocked; unsupported host-list fields are omitted and - // the proxy enforces the full list. - assert_eq!(cfg["network"]["defaultPolicy"], "block"); - assert!(cfg["network"].get("allowedHosts").is_none()); - assert!(cfg["network"].get("blockedHosts").is_none()); + // Direct egress is denied; the host proxy enforces the list. + assert_eq!(cfg["network"]["egress"]["default"], "deny"); // Filesystem grants are preserved unchanged. assert_eq!( @@ -401,7 +389,7 @@ fn split_policy_rejects_proxy_redirect_on_isolation_session() { "expected one containment error: {errors:?}" ); assert_eq!(errors[0].path, "containment"); - assert!(errors[0].message.contains("MXC M1")); + assert!(errors[0].message.contains("processcontainer")); assert!(result.mxc_config["network"].get("proxy").is_none()); } @@ -425,9 +413,7 @@ fn network_only_policy_has_empty_filesystem() { // ── New tests: proxy JSON shape and non-127.0.0.1 guard ────────────────────── #[test] -fn split_with_loopback_addr_emits_localhost_port_shape() { - // MXC 0.6.0-alpha accepts ONLY {"proxy": {"localhost": N}}. - // Verified against the real wxc-exec 0.6.0-alpha binary via --dry-run. +fn split_with_loopback_addr_emits_loopback_only_08_shape() { let path = examples_root().join("sandbox-policy-quickstart/policy.yaml"); let yaml = std::fs::read_to_string(&path).expect("read quickstart"); let policy = parse_sandbox_policy(&yaml).expect("parse quickstart"); @@ -440,18 +426,15 @@ fn split_with_loopback_addr_emits_localhost_port_shape() { let result = split_policy(&policy, &opts).expect("split returns Some"); let cfg = &result.mxc_config; + assert_eq!(cfg["version"], "0.8.0-alpha"); + assert_eq!(cfg["network"]["egress"]["default"], "deny"); assert_eq!( - cfg["network"]["proxy"]["localhost"], 18080, - "proxy must use {{\"localhost\": N}} shape" - ); - assert!( - cfg["network"]["proxy"].get("host").is_none(), - "proxy must not contain 'host' key" - ); - assert!( - cfg["network"]["proxy"].get("port").is_none(), - "proxy must not contain 'port' key" + cfg["network"]["egress"]["allow"][0]["to"][0]["cidr"], + "127.0.0.1/32" ); + assert_eq!(cfg["network"]["ingress"]["hostLoopback"], "allow"); + assert!(cfg.get("runtimeConfig").is_none()); + assert!(cfg["network"].get("proxy").is_none()); // No error losses — 127.0.0.1 is representable. assert!( result.loss.iter().all(|i| i.severity != "error"), @@ -465,9 +448,7 @@ fn split_with_loopback_addr_emits_localhost_port_shape() { } #[test] -fn split_with_non_loopback_addr_emits_error_loss_and_no_proxy_block() { - // Non-127.0.0.1 redirect addresses are not representable in MXC 0.6.0-alpha. - // The mapper must record an error loss and omit the proxy block. +fn split_with_non_loopback_addr_emits_error_loss_and_no_runtime_proxy() { let path = examples_root().join("sandbox-policy-quickstart/policy.yaml"); let yaml = std::fs::read_to_string(&path).expect("read quickstart"); let policy = parse_sandbox_policy(&yaml).expect("parse quickstart"); @@ -480,28 +461,29 @@ fn split_with_non_loopback_addr_emits_error_loss_and_no_proxy_block() { let result = split_policy(&policy, &opts).expect("split returns Some"); let cfg = &result.mxc_config; - // Proxy block must be absent. + // A runtime proxy block is never emitted; proxy-aware clients receive + // environment variables from the driver after this mapping step. assert!( - cfg["network"].get("proxy").is_none() || cfg["network"]["proxy"].is_null(), - "non-127.0.0.1 redirect must not produce a proxy block: {:?}", - cfg["network"].get("proxy") + cfg.get("runtimeConfig").is_none() || cfg["runtimeConfig"].is_null(), + "non-127.0.0.1 redirect must not produce runtimeConfig: {:?}", + cfg.get("runtimeConfig") ); - // An error loss for "network.proxy" must be present. + // An error loss for the unusable redirect must be present. let proxy_loss = result .loss .iter() - .find(|i| i.path == "network.proxy" && i.severity == "error"); + .find(|i| i.path == "proxy_redirect" && i.severity == "error"); assert!( proxy_loss.is_some(), - "non-127.0.0.1 redirect must produce an error loss item on network.proxy: {:?}", + "non-127.0.0.1 redirect must produce a proxy_redirect error loss item: {:?}", result.loss ); let loss = proxy_loss.unwrap(); assert_eq!(loss.openshell_feature, "per-sandbox egress attribution"); assert!( - loss.message.contains("localhost"), - "loss message should mention 'localhost': {}", + loss.message.contains("127.0.0.1"), + "loss message should mention '127.0.0.1': {}", loss.message ); } diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs index 6dda646bd0..28e0fdb585 100644 --- a/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs +++ b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs @@ -24,7 +24,7 @@ use openshell_core::proto::{ FilesystemPolicy, GraphqlOperation, L7Allow, L7DenyRule, L7Rule, LandlockPolicy, MiddlewareEndpointSelector, NetworkBinary, NetworkEndpoint, NetworkMiddlewareConfig, - NetworkPolicyRule, ProcessPolicy, SandboxPolicy, + NetworkPolicyRule, ProcessPolicy, SandboxPolicy, UiClipboardAccess, UiPolicy, }; use openshell_driver_mxc::{ EmbeddedPolicyMapper, MapCtx, MapError, MxcMappingOptions, PolicyMapper, map_to_mxc, @@ -82,6 +82,12 @@ fn pc_split_opts() -> MxcMappingOptions { } } +fn pc_opts() -> MxcMappingOptions { + MxcMappingOptions { + containment: "processcontainer".to_owned(), + ..Default::default() + } +} /// Build a minimal policy with one network rule whose endpoints carry a single /// endpoint set up by the caller. fn net_policy(key: &str, ep: NetworkEndpoint) -> SandboxPolicy { @@ -384,18 +390,18 @@ fn a_split_network_verbatim_and_version_preserved() { ); } -/// split path: mxc_config["network"]["proxy"]["localhost"] == port (new schema). +/// Split path emits MXC 0.8 loopback-only governed-egress fields. #[test] -fn a_split_proxy_localhost_port() { +fn a_split_proxy_uses_loopback_only_08_fields() { let policy = SandboxPolicy::default(); let result = split_policy(&policy, &pc_split_opts()).expect("split must return Some"); + assert!(result.mxc_config.get("runtimeConfig").is_none()); + assert_eq!(result.mxc_config["version"], "0.8.0-alpha"); + assert_eq!(result.mxc_config["network"]["egress"]["default"], "deny"); assert_eq!( - result.mxc_config["network"]["proxy"]["localhost"], 18080, - "split must emit network.proxy.localhost == port" + result.mxc_config["network"]["egress"]["allow"][0]["to"][0]["cidr"], + "127.0.0.1/32" ); - // Released wxc-exec rejects host-list fields, even when empty. - assert!(result.mxc_config["network"].get("allowedHosts").is_none()); - assert!(result.mxc_config["network"].get("blockedHosts").is_none()); } // ─── QUADRANT B: OpenShell features MXC cannot express ────────────────────── @@ -931,6 +937,7 @@ fn b_seam_returns_unsupported_on_error_field() { let ctx = MapCtx { sandbox_id: "sb-test".into(), egress: None, // coarse path → isolation_session → network policy errors + containment: "isolation_session".into(), }; let err = mapper.map(Some(&policy), &ctx).unwrap_err(); assert!( @@ -1024,13 +1031,141 @@ fn c_split_empty_allowed_hosts_with_network_rules() { }, ); let result = split_policy(&policy, &pc_split_opts()).expect("split must return Some"); - assert!(result.mxc_config["network"].get("allowedHosts").is_none()); - assert!(result.mxc_config["network"].get("blockedHosts").is_none()); - // But proxy redirect is present. assert_eq!( - result.mxc_config["network"]["proxy"]["localhost"], 18080, - "split must emit network.proxy.localhost" + result.mxc_config["network"]["egress"]["default"], "deny", + "split path must deny direct egress even with network rules" ); + // The only MXC-level egress allowance is host loopback. + assert_eq!( + result.mxc_config["network"]["egress"]["allow"][0]["to"][0]["cidr"], + "127.0.0.1/32" + ); + assert!(result.mxc_config.get("runtimeConfig").is_none()); +} + +#[test] +fn a_processcontainer_maps_ui_capabilities_exactly() { + for (clipboard, expected) in [ + (UiClipboardAccess::Unspecified, "none"), + (UiClipboardAccess::None, "none"), + (UiClipboardAccess::Read, "read"), + (UiClipboardAccess::Write, "write"), + (UiClipboardAccess::All, "all"), + ] { + let policy = SandboxPolicy { + ui: Some(UiPolicy { + allow_graphical_ui: true, + clipboard: clipboard as i32, + allow_input_injection: true, + }), + ..Default::default() + }; + let result = map_to_mxc(&policy, &pc_opts()); + assert_eq!(result.config["ui"]["disable"], false); + assert_eq!(result.config["ui"]["clipboard"], expected); + assert_eq!(result.config["ui"]["injection"], true); + assert_eq!(result.config["ui"].as_object().unwrap().len(), 3); + assert!(result.loss.iter().all(|item| item.path != "ui")); + } +} + +#[test] +fn c_processcontainer_absent_or_empty_ui_is_default_deny() { + for policy in [ + SandboxPolicy::default(), + SandboxPolicy { + ui: Some(UiPolicy::default()), + ..Default::default() + }, + ] { + let result = map_to_mxc(&policy, &pc_opts()); + assert_eq!(result.config["ui"]["disable"], true); + assert_eq!(result.config["ui"]["clipboard"], "none"); + assert_eq!(result.config["ui"]["injection"], false); + } +} + +#[test] +fn b_processcontainer_rejects_clipboard_without_graphical_ui() { + let policy = SandboxPolicy { + ui: Some(UiPolicy { + clipboard: UiClipboardAccess::Read as i32, + ..Default::default() + }), + ..Default::default() + }; + let result = map_to_mxc(&policy, &pc_opts()); + assert_eq!(result.config["ui"]["disable"], true); + assert_eq!(result.config["ui"]["clipboard"], "none"); + assert_single_loss( + &result.loss, + "ui.clipboard", + "error", + "clipboard grant suppressed by ui.disable", + ); +} + +#[test] +fn b_processcontainer_rejects_input_injection_without_graphical_ui() { + let policy = SandboxPolicy { + ui: Some(UiPolicy { + allow_input_injection: true, + ..Default::default() + }), + ..Default::default() + }; + let result = map_to_mxc(&policy, &pc_opts()); + assert_eq!(result.config["ui"]["disable"], true); + assert_eq!(result.config["ui"]["injection"], false); + assert_single_loss( + &result.loss, + "ui.allow_input_injection", + "error", + "input-injection grant suppressed by ui.disable", + ); +} + +#[test] +fn b_isolation_session_omits_absent_ui_and_rejects_explicit_ui() { + let opts = MxcMappingOptions { + containment: "isolation_session".into(), + ..Default::default() + }; + let absent = map_to_mxc(&SandboxPolicy::default(), &opts); + assert!(absent.config.get("ui").is_none()); + + let explicit = map_to_mxc( + &SandboxPolicy { + ui: Some(UiPolicy::default()), + ..Default::default() + }, + &opts, + ); + assert!(explicit.config.get("ui").is_none()); + assert_single_loss( + &explicit.loss, + "ui", + "error", + "isolation_session explicit UI", + ); +} + +#[test] +fn a_split_maps_ui_to_mxc_and_omits_it_from_proxy_policy() { + let policy = SandboxPolicy { + version: 1, + ui: Some(UiPolicy { + allow_graphical_ui: true, + clipboard: UiClipboardAccess::Write as i32, + ..Default::default() + }), + ..Default::default() + }; + let result = split_policy(&policy, &pc_split_opts()).expect("split"); + assert_eq!(result.mxc_config["ui"]["disable"], false); + assert_eq!(result.mxc_config["ui"]["clipboard"], "write"); + assert_eq!(result.mxc_config["ui"]["injection"], false); + assert!(result.proxy_policy.ui.is_none()); } // ─── DRIFT GUARD ───────────────────────────────────────────────────────────── @@ -1047,11 +1182,14 @@ fn c_split_empty_allowed_hosts_with_network_rules() { /// "landlock" — loss item emitted in add_static_policy_loss /// "process" — loss items for run_as_user / run_as_group /// "network_policies" — mapped via map_network / delegated in split +/// "network_middlewares" — error loss in coarse map / delegated in split +/// "ui" — exact processContainer map / explicit unsupported loss const HANDLED_TOPLEVEL: &[&str] = &[ "version", "filesystem_policy", "landlock", "process", + "ui", "network_policies", "network_middlewares", ]; @@ -1122,6 +1260,7 @@ fn handled_fields_inventory() { run_as_user: "sandbox".into(), run_as_group: "sandbox".into(), }), + ui: Some(UiPolicy::default()), network_policies: { let mut m = std::collections::HashMap::new(); m.insert( diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs index df4c3a5dba..7517738e67 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -37,7 +37,7 @@ use openshell_core::proto::{ NetworkPolicyRule, NetworkTlsMode, SandboxPolicy, }; use openshell_driver_mxc::{MxcComputeBackend, MxcComputeConfig}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::Command; use std::time::Duration; @@ -69,7 +69,7 @@ fn wxc_path() -> Option { /// Create a real, user-owned Windows directory for MXC filesystem grants. /// /// MXC config values are literal paths: it does not expand `%TEMP%`. A unique -/// directory also keeps AppContainer+DACL fallback mutations scoped to test +/// directory also keeps `AppContainer`+DACL fallback mutations scoped to test /// data the current user owns. fn temp_fixture() -> (tempfile::TempDir, String) { let dir = tempfile::tempdir().expect("create MXC temp fixture"); @@ -82,10 +82,19 @@ fn temp_fixture() -> (tempfile::TempDir, String) { /// Invoke `wxc-exec --config-base64 --dry-run` synchronously. /// Returns `(exit_code, stdout, stderr)`. fn dry_run(wxc: &PathBuf, config: &serde_json::Value) -> (i32, String, String) { + dry_run_with_args(wxc, config, &[]) +} + +fn dry_run_with_args( + wxc: &PathBuf, + config: &serde_json::Value, + args: &[&str], +) -> (i32, String, String) { let json = serde_json::to_string(config).expect("config serialize"); let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); let out = Command::new(wxc) + .args(args) .arg("--config-base64") .arg(&b64) .arg("--dry-run") @@ -98,6 +107,32 @@ fn dry_run(wxc: &PathBuf, config: &serde_json::Value) -> (i32, String, String) { (code, stdout, stderr) } +fn wxc_version(wxc: &Path) -> Option<(u64, u64, u64, String)> { + // wxc-exec does not expose --version. Release builds carry the Cargo + // version in the standard Windows ProductVersion resource. + let path_literal = wxc.to_string_lossy().replace('\'', "''"); + let output = Command::new("powershell.exe") + .args(["-NoProfile", "-NonInteractive", "-Command"]) + .arg(format!( + "(Get-Item -LiteralPath '{path_literal}').VersionInfo.ProductVersion" + )) + .output() + .ok()?; + let raw = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let version = raw.split_whitespace().find_map(|token| { + let core = token + .trim_matches(|ch: char| !ch.is_ascii_digit() && ch != '.') + .split(['+', '-']) + .next()?; + let mut parts = core.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + let patch = parts.next()?.parse().ok()?; + Some((major, minor, patch)) + })?; + Some((version.0, version.1, version.2, raw)) +} + // ── (a) Dry-run contract tests ──────────────────────────────────────────────── // // These PASS on any box that has the wxc-exec binary — no enforcement backend @@ -134,6 +169,115 @@ fn dryrun_accepts_minimal_processcontainer_config() { ); } +/// Every `OpenShell` clipboard direction maps to MXC's shared top-level UI +/// contract, with graphical UI and injection carried as independent booleans. +#[test] +#[ignore = "requires real wxc-exec"] +fn dryrun_accepts_processcontainer_ui_policy_matrix() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + let tempdir = tempfile::tempdir().expect("tempdir"); + let temp_path = tempdir.path().to_string_lossy().into_owned(); + for clipboard in ["none", "read", "write", "all"] { + let config = serde_json::json!({ + "version": "0.7.0-alpha", + "containerId": format!("test-ui-{clipboard}"), + "containment": "processcontainer", + "process": { + "commandLine": "cmd /c exit 0", + "cwd": temp_path.clone(), + "timeout": 0, + }, + "filesystem": { + "readwritePaths": [temp_path.clone()], + }, + "ui": { + "disable": false, + "clipboard": clipboard, + "injection": true, + }, + }); + + let (code, stdout, stderr) = dry_run(&wxc, &config); + assert_eq!( + code, 0, + "processcontainer UI policy clipboard={clipboard} rejected by --dry-run\nstdout={stdout}\nstderr={stderr}" + ); + } +} + +/// MXC 0.8 and the 0.9 development schema reject the shared top-level +/// UI object on `isolation_session`, while omission remains accepted. Older +/// 0.7 builds accepted and ignored the object, so `OpenShell`'s gateway-level +/// capability check is the stable enforcement boundary across versions. +#[test] +#[ignore = "requires real wxc-exec"] +fn dryrun_current_schema_rejects_isolation_session_ui() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + let Some((major, minor, _patch, raw_version)) = wxc_version(&wxc) else { + eprintln!("SKIP: could not determine wxc-exec version"); + return; + }; + if (major, minor) < (0, 8) { + eprintln!("SKIP: {raw_version} predates the isolation_session UI rejection contract"); + return; + } + + let mut base = serde_json::json!({ + "version": "0.8.0-alpha", + "phase": "provision", + "containment": "isolation_session", + "network": { + "defaultPolicy": "allow", + "allowLocalNetwork": true, + }, + }); + let args: &[&str] = if (major, minor) >= (0, 9) { + base.as_object_mut().unwrap().remove("phase"); + base["version"] = serde_json::json!("0.9.0-alpha"); + base["network"] = serde_json::json!({ + "egress": { "default": "allow" }, + "ingress": { "default": "allow", "hostLoopback": "allow" }, + }); + &["--experimental", "--operation", "provision"] + } else { + &["--experimental"] + }; + let (code, stdout, stderr) = dry_run_with_args(&wxc, &base, args); + let output = format!("{stdout} {stderr}").to_ascii_lowercase(); + if code != 0 + && output.contains("backend_unavailable") + && output.contains("not available in this build") + { + eprintln!("SKIP: {raw_version} was built without isolation_session support"); + return; + } + assert_eq!( + code, 0, + "current isolation_session schema must accept omission of UI\nversion={raw_version}\nstdout={stdout}\nstderr={stderr}" + ); + + let mut with_ui = base; + with_ui["ui"] = serde_json::json!({ "disable": true }); + let (code, stdout, stderr) = dry_run_with_args(&wxc, &with_ui, args); + assert_ne!( + code, 0, + "current isolation_session schema unexpectedly accepted UI\nversion={raw_version}\nstdout={stdout}\nstderr={stderr}" + ); + assert!( + format!("{stdout} {stderr}") + .to_ascii_lowercase() + .contains("ui"), + "rejection should identify UI\nversion={raw_version}\nstdout={stdout}\nstderr={stderr}" + ); +} + /// Network block without proxy (defaultPolicy block, empty host lists) accepted. #[test] #[ignore = "requires real wxc-exec"] @@ -273,7 +417,8 @@ fn dryrun_rejects_unknown_containment() { } /// The most important dry-run test: build a typed Windows policy, run -/// `split_policy` (`proxy_redirect` 127.0.0.1:18080, containment +/// `split_policy` (MXC 0.8 loopback-only proxy access at 127.0.0.1:18080, +/// containment /// "processcontainer"), and verify the resulting config with `--dry-run`. /// /// This proves that the mapper's emitted JSON is accepted by the real binary — @@ -322,8 +467,38 @@ fn dryrun_accepts_split_policy_output() { } let mxc_config = result.mxc_config; + assert_eq!(mxc_config["version"], "0.8.0-alpha"); + assert_eq!(mxc_config["network"]["egress"]["default"], "deny"); + assert_eq!( + mxc_config["network"]["egress"]["allow"][0]["to"][0]["cidr"], + "127.0.0.1/32" + ); + assert!(mxc_config.get("runtimeConfig").is_none()); + // --dry-run also resolves host capabilities; it is not schema-only. + // Verify explicit rejection on an AppContainer-only host rather than + // weakening the mapper's required loopback fence to obtain a green test. + let probe = Command::new(&wxc) + .arg("--probe") + .output() + .expect("MXC probe"); + let probe_json = serde_json::from_slice::(&probe.stdout).ok(); + let loopback_supported = probe_json + .as_ref() + .and_then(|value| value.pointer("/probes/baseContainerSupportsIngressHostLoopbackAllow")) + .and_then(serde_json::Value::as_bool); let (code, stdout, stderr) = dry_run(&wxc, &mxc_config); + if loopback_supported == Some(false) { + assert_ne!(code, 0, "unsupported host must reject the loopback fence"); + assert!( + stderr.contains("hostLoopback"), + "unexpected rejection: {stderr}" + ); + eprintln!( + "SKIP: positive split-policy admission requires native host-loopback support; unsupported-host rejection verified" + ); + return; + } assert_eq!( code, 0, @@ -763,8 +938,6 @@ async fn pc_https_egress_reads_injected_ca_bundle() { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let config = MxcComputeConfig { wxc_exec_path: wxc.to_string_lossy().into_owned(), - egress_proxy: true, - egress_proxy_addr: "127.0.0.1:18080".to_string(), ..Default::default() }; let backend = MxcComputeBackend::new(openshell_core::config::DEFAULT_GATEWAY_NAME, config); diff --git a/crates/openshell-driver-vm/src/isolation/mod.rs b/crates/openshell-driver-vm/src/isolation/mod.rs index a660ad98c7..c89f9284dd 100644 --- a/crates/openshell-driver-vm/src/isolation/mod.rs +++ b/crates/openshell-driver-vm/src/isolation/mod.rs @@ -133,6 +133,7 @@ impl VmBoundarySpec { // reserved host aliases terminate at its loopback address // after crossing the authenticated boundary channel. host_gateway_ip: Some(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)), + direct_proxy: None, resource_claims, outer_fence, }, diff --git a/crates/openshell-gateway/src/lib.rs b/crates/openshell-gateway/src/lib.rs index dc0ded3f0b..96e106f370 100644 --- a/crates/openshell-gateway/src/lib.rs +++ b/crates/openshell-gateway/src/lib.rs @@ -149,8 +149,8 @@ impl openshell_server::ComputeDriverFactory for MxcFactory { &self, context: openshell_server::ComputeDriverConfigContext<'_>, ) -> openshell_core::Result<()> { - let _: openshell_driver_mxc::MxcComputeConfig = context.driver_config()?; - Ok(()) + let config: openshell_driver_mxc::MxcComputeConfig = context.driver_config()?; + config.validate_configuration() } async fn build( @@ -158,7 +158,14 @@ impl openshell_server::ComputeDriverFactory for MxcFactory { context: openshell_server::ComputeDriverBuildContext<'_>, ) -> openshell_core::Result { let config: openshell_driver_mxc::MxcComputeConfig = context.driver_config()?; - let backend = openshell_driver_mxc::MxcComputeBackend::new(context.gateway_name(), config); + require_guest_tls_for_local_driver(&context, "mxc")?; + let backend = openshell_driver_mxc::MxcComputeBackend::for_gateway( + context.gateway_name(), + config, + context.gateway_port(), + context.gateway_tls_enabled(), + context.guest_tls_ca().map(std::path::Path::to_path_buf), + ); let driver = openshell_driver_mxc::ComputeDriverService::new(backend); Ok(openshell_server::ComputeDriverInstance::InProcess( std::sync::Arc::new(driver), @@ -438,13 +445,16 @@ fn vm_config( Ok(config) } -#[cfg(all( - not(target_os = "windows"), - any( - feature = "compute-driver-docker", - feature = "compute-driver-podman", - feature = "compute-driver-vm" - ) +#[cfg(any( + all( + not(target_os = "windows"), + any( + feature = "compute-driver-docker", + feature = "compute-driver-podman", + feature = "compute-driver-vm" + ) + ), + all(target_os = "windows", feature = "compute-driver-mxc") ))] fn require_guest_tls_for_local_driver( context: &openshell_server::ComputeDriverBuildContext<'_>, @@ -457,13 +467,16 @@ fn require_guest_tls_for_local_driver( ) } -#[cfg(all( - not(target_os = "windows"), - any( - feature = "compute-driver-docker", - feature = "compute-driver-podman", - feature = "compute-driver-vm" - ) +#[cfg(any( + all( + not(target_os = "windows"), + any( + feature = "compute-driver-docker", + feature = "compute-driver-podman", + feature = "compute-driver-vm" + ) + ), + all(target_os = "windows", feature = "compute-driver-mxc") ))] fn validate_local_driver_guest_tls( gateway_tls_enabled: bool, diff --git a/crates/openshell-gateway/tests/mxc_config_preflight.rs b/crates/openshell-gateway/tests/mxc_config_preflight.rs new file mode 100644 index 0000000000..c4314f3079 --- /dev/null +++ b/crates/openshell-gateway/tests/mxc_config_preflight.rs @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Integration test for the actual enforcement boundary of the +//! `wxc_exec_path` absolute-path requirement: the real `openshell-gateway` +//! binary's `config preflight` subcommand, selecting the `mxc` compute +//! driver. +//! +//! `openshell_driver_mxc::MxcComputeConfig::validate_configuration` already +//! has unit coverage, but that only proves the validation function itself is +//! correct -- it says nothing about whether `MxcFactory::validate_config` (in +//! `src/lib.rs`) still calls it. Before this fix, that factory method was a +//! no-op that discarded the parsed config entirely, so a regression back to +//! that shape would leave every unit test passing while a relative +//! `wxc_exec_path` again reached gateway startup. Spawning the real compiled +//! binary through its actual CLI entry point exercises the whole chain: CLI +//! parsing, TOML config loading, driver selection, `MxcFactory::validate_config`, +//! and `MxcComputeConfig::validate_configuration`. +//! +//! These tests assert only pass/fail, not the diagnostic's exact wording: +//! `openshell_server::cli::run_effective_config_preflight` replaces any +//! validation failure with a generic "malformed" message whenever a config +//! file path is in play (`ConfigPreflightError::invalid_current`), so the +//! specific `wxc_exec_path` wording from `validate_configuration` is not +//! observable through this boundary today. That message-masking behavior is +//! pre-existing and unrelated to this fix. + +#![cfg(all(target_os = "windows", feature = "compute-driver-mxc"))] + +use std::io::Write; +use std::process::Command; + +/// Run `openshell-gateway config preflight` against a disposable TOML config +/// with the `mxc` driver selected. Returns whether the process exited +/// successfully and its captured stderr. +fn run_preflight(toml_body: &str) -> (bool, String) { + let mut config_file = tempfile::NamedTempFile::new().expect("create temp config file"); + write!(config_file, "{toml_body}").expect("write temp config file"); + + let output = Command::new(env!("CARGO_BIN_EXE_openshell-gateway")) + .arg("config") + .arg("preflight") + .env("OPENSHELL_GATEWAY_CONFIG", config_file.path()) + .env("OPENSHELL_COMPUTE_DRIVER", "mxc") + .env("OPENSHELL_DISABLE_TLS", "true") + .env_remove("OPENSHELL_DRIVERS") + .output() + .expect("spawn openshell-gateway config preflight"); + + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + (output.status.success(), stderr) +} + +#[test] +fn config_preflight_rejects_omitted_wxc_exec_path() { + let (ok, stderr) = run_preflight( + r" +[openshell] +version = 2 +", + ); + assert!( + !ok, + "omitted wxc_exec_path must fail preflight, stderr: {stderr}" + ); +} + +#[test] +fn config_preflight_rejects_relative_wxc_exec_path() { + let (ok, stderr) = run_preflight( + r#" +[openshell] +version = 2 + +[openshell.drivers.mxc] +wxc_exec_path = "wxc-exec.exe" +"#, + ); + assert!( + !ok, + "a relative wxc_exec_path must fail preflight, stderr: {stderr}" + ); +} + +#[test] +fn config_preflight_accepts_absolute_wxc_exec_path() { + let (ok, stderr) = run_preflight( + r#" +[openshell] +version = 2 + +[openshell.drivers.mxc] +wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe" +"#, + ); + assert!( + ok, + "an absolute wxc_exec_path must pass preflight, stderr: {stderr}" + ); +} diff --git a/crates/openshell-isolation-interface/src/contract.rs b/crates/openshell-isolation-interface/src/contract.rs index ab3e10752f..9f619c0919 100644 --- a/crates/openshell-isolation-interface/src/contract.rs +++ b/crates/openshell-isolation-interface/src/contract.rs @@ -370,6 +370,15 @@ pub trait BoundBoundary: Send { /// Retained by the supervisor before consuming `Bound`. fn network_mediation_source(&self) -> Arc; + /// Driver-provisioned direct proxy listener for backends whose outer + /// fence can route workload traffic to a host listener but cannot stage + /// individual socket opens. The supervisor owns this listener and its + /// policy evaluation; the generation-scoped authorization prevents other + /// local processes from entering the sandbox's policy context. + fn direct_proxy_configuration(&self) -> Option { + None + } + /// Trusted host-side dial target for the well-known host-gateway aliases. /// /// Backends return this when the mediation service runs outside the @@ -386,6 +395,31 @@ pub trait BoundBoundary: Send { async fn confirm(self: Box) -> Result; } +/// Authenticated host listener used by an isolation backend's explicit-proxy +/// path. +/// +/// This is control-plane material and must be delivered through the protected +/// runtime descriptor, never command-line arguments or logs. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DirectProxyConfiguration { + pub bind_addr: SocketAddr, + /// Exact HTTP `Proxy-Authorization` value required from this generation. + pub authorization: String, + /// Driver-resolved identity applied to direct-listener requests. + pub binary_identity: BinaryIdentity, +} + +impl fmt::Debug for DirectProxyConfiguration { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("DirectProxyConfiguration") + .field("bind_addr", &self.bind_addr) + .field("authorization", &"") + .field("binary_identity", &self.binary_identity) + .finish() + } +} + /// Backend-neutral guarantees established by the component that owns the outer /// network fence. /// @@ -478,7 +512,6 @@ pub struct EnforcedProperty { pub enforced: bool, pub mechanism: String, } - impl EnforcedProperty { #[must_use] pub fn new(enforced: bool, mechanism: impl Into) -> Self { @@ -857,7 +890,7 @@ pub trait BoundaryLoopbackConnector: Send + Sync { /// that owns a connection or one of its executable ancestors. A missing digest /// is `None`, never an empty value; binary-scoped policy cannot authorize an /// executable whose digest is unavailable. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ExecutableIdentity { /// Absolute executable path in the workload filesystem namespace. pub path: PathBuf, @@ -871,7 +904,7 @@ pub struct ExecutableIdentity { /// /// How a backend resolves identity is private to that backend; the shape and /// fail-closed semantics do not change. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct BinaryIdentity { /// Executable that owns the accepted connection. pub executable: ExecutableIdentity, diff --git a/crates/openshell-isolation-interface/tests/backend_conformance.rs b/crates/openshell-isolation-interface/tests/backend_conformance.rs index 2fb1f3fdba..c1873db08a 100644 --- a/crates/openshell-isolation-interface/tests/backend_conformance.rs +++ b/crates/openshell-isolation-interface/tests/backend_conformance.rs @@ -383,7 +383,6 @@ fn complete_outer_fence(generation: &str, evidence: &[u8]) -> OuterFenceGuarante ) .unwrap() } - fn confirmation() -> BoundaryConfirmation { BoundaryConfirmation { generation: "generation-1".to_string(), diff --git a/crates/openshell-mxc-boundary/Cargo.toml b/crates/openshell-mxc-boundary/Cargo.toml new file mode 100644 index 0000000000..c11a197434 --- /dev/null +++ b/crates/openshell-mxc-boundary/Cargo.toml @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-mxc-boundary" +description = "Windows MXC implementation of the authenticated OpenShell sandbox boundary" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[[bin]] +name = "openshell-windows-sandbox" +path = "src/main.rs" + +[dependencies] +clap = { workspace = true } + +[target.'cfg(target_os = "windows")'.dependencies] +async-trait = "0.1" +openshell-core = { path = "../openshell-core", default-features = false } +openshell-isolation-interface = { path = "../openshell-isolation-interface" } +openshell-sandbox-backend = { path = "../openshell-sandbox-backend" } +openshell-ocsf = { path = "../openshell-ocsf" } +tracing-subscriber = { workspace = true } +tokio = { workspace = true } +tokio-stream = { workspace = true } +tonic = { workspace = true } +rustls = { workspace = true } +rustls-pemfile = { workspace = true } +tokio-rustls = { workspace = true } +serde_json = { workspace = true } +serde = { workspace = true } +tracing = { workspace = true } +url = { workspace = true } +windows = { workspace = true } + +[lints] +workspace = true diff --git a/crates/openshell-mxc-boundary/README.md b/crates/openshell-mxc-boundary/README.md new file mode 100644 index 0000000000..b768fec8ca --- /dev/null +++ b/crates/openshell-mxc-boundary/README.md @@ -0,0 +1,54 @@ +# openshell-mxc-boundary + +Windows-only library implementing the authenticated Sandbox Protocol inside an +MXC ProcessContainer, with a thin `openshell-windows-sandbox.exe` entrypoint. +The generic `openshell-sandbox` does not link this implementation. There is no +separate supervisor-relay executable. + +The Windows release lane builds this executable alongside the gateway, CLI, and +host supervisor. It accepts `--bootstrap ` and `--log-level `; +bootstrap configuration remains one-use and protected by the driver. On other +platforms, runtime execution returns a clear unsupported error. + +The compute driver owns provisioning, filesystem/network policy mapping, ETW, +protected bootstrap material, and monitoring the host/boundary process pair. +This library owns Windows process launch and I/O, AppContainer confirmation, +authenticated lifecycle operations, retained output, and loopback forwarding. +The separate host supervisor owns policy evaluation, provider credentials, and +upstream networking. This library does not depend on the compute driver or the +supervisor implementation. + +The library also owns the host-side `MxcRuntimeBackend`, registered under the +exact admission name `openshell-mxc`, and the MXC audit schema and its validator. +Windows supervisor composition selects this backend; it delegates transport +and common lifecycle validation to the shared Sandbox Protocol implementation. +The backend keeps evidence opaque and compares validated properties with the +confirmation; its default Linux validator rejects Windows evidence. + +MXC adopts main's authenticated image-policy discovery and ordered provider +environment publication. Command-based MXC has no rootfs image policy to +discover. Publication generations prevent stale refreshes from replacing newer +credentials, independently of opaque provider revisions. Provider file delivery +is unsupported and rejected before process launch or environment replacement. +Structured shell exec intent and runtime helpers are also rejected explicitly; +they must not be silently treated as raw executable launches. + +Dynamic service forwarding uses the shared protocol's `LoopbackConnect` request +and bidirectional stream. The boundary connects to the requested loopback port; +it does not launch a relay or connect back to a new gateway relay listener. +Non-loopback targets and port zero are rejected. Authentication and lifecycle +authorization precede dispatch to the forwarding operation. + +This extraction preserves the current explicit-proxy network model. It does +not add transparent socket interception or a non-PSEC transport. The outer fence +still requires native ProcessContainer networking and permits all ports on +`127.0.0.1`; it is not isolation from unrelated host-loopback services. + +Main requires explicit proof of no unmanaged egress path and verified +revocation. The current driver publishes no established outer-fence guarantees +because its configuration alone does not prove those properties. Confirmation +therefore remains fail-closed, including on an otherwise qualified PSEC host. + +Unit tests exercise real Windows process launch, output retention, and a real +ephemeral TCP forwarding target. These tests do not qualify MXC enforcement; +that requires the real-MXC integration and E2E lanes on a supported host. diff --git a/crates/openshell-mxc-boundary/src/audit.rs b/crates/openshell-mxc-boundary/src/audit.rs new file mode 100644 index 0000000000..a795eb0a51 --- /dev/null +++ b/crates/openshell-mxc-boundary/src/audit.rs @@ -0,0 +1,169 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! MXC-owned containment evidence and its host-side interpretation. + +use openshell_isolation_interface::contract::{BackendError, BoundaryProperties, EnforcedProperty}; +use openshell_sandbox_backend::audit::BoundaryAuditValidator; +use serde::{Deserialize, Serialize}; + +/// Windows `ProcessContainer` evidence measured by the MXC boundary. +/// +/// MXC supplies the outer filesystem and network fence; the in-container +/// sandbox supplies authenticated lifecycle and process I/O. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +#[allow( + clippy::struct_excessive_bools, + reason = "audit evidence preserves independently measured security results" +)] +pub struct MxcSandboxAuditEvidence { + pub process_container: bool, + pub appcontainer_profile: String, + pub default_deny_filesystem: bool, + pub default_deny_egress: bool, + pub loopback_proxy_only: bool, + pub authenticated_control: bool, + pub generation_scoped_attribution: bool, +} + +impl MxcSandboxAuditEvidence { + /// Validate all containment and authentication measurements. + /// + /// # Errors + /// + /// Returns an error when any required measurement is missing or failed. + pub fn validate(&self) -> Result<(), BackendError> { + if self.process_container + && !self.appcontainer_profile.trim().is_empty() + && self.default_deny_filesystem + && self.default_deny_egress + && self.loopback_proxy_only + && self.authenticated_control + && self.generation_scoped_attribution + { + Ok(()) + } else { + Err(BackendError::Confirm( + "MXC sandbox audit evidence is incomplete".to_string(), + )) + } + } + + #[must_use] + pub fn properties(&self) -> BoundaryProperties { + BoundaryProperties { + filesystem_confinement: EnforcedProperty::new( + self.default_deny_filesystem, + "mxc-processcontainer-appcontainer", + ), + egress_interception: EnforcedProperty::new( + self.default_deny_egress && self.loopback_proxy_only, + "mxc-wfp-loopback-proxy-fence", + ), + request_attribution: EnforcedProperty::new( + self.generation_scoped_attribution, + "mxc-generation-authenticated-proxy", + ), + privilege_floor: EnforcedProperty::new( + self.process_container, + "windows-appcontainer-token", + ), + } + } +} + +/// MXC wire format; the generic transport does not interpret platform evidence. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "platform", content = "evidence", rename_all = "snake_case")] +pub enum MxcBoundaryAuditEvidence { + WindowsMxc(MxcSandboxAuditEvidence), +} + +/// Host-side MXC evidence interpreter selected by Windows backend composition. +#[derive(Debug)] +pub struct MxcBoundaryAuditValidator; + +impl BoundaryAuditValidator for MxcBoundaryAuditValidator { + fn validate(&self, evidence: &serde_json::Value) -> Result { + let MxcBoundaryAuditEvidence::WindowsMxc(audit) = serde_json::from_value(evidence.clone()) + .map_err(|error| { + BackendError::Confirm(format!("decode MXC sandbox audit evidence: {error}")) + })?; + audit.validate()?; + Ok(audit.properties()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn valid_evidence() -> serde_json::Value { + serde_json::to_value(MxcBoundaryAuditEvidence::WindowsMxc( + MxcSandboxAuditEvidence { + process_container: true, + appcontainer_profile: "measured-profile".to_string(), + default_deny_filesystem: true, + default_deny_egress: true, + loopback_proxy_only: true, + authenticated_control: true, + generation_scoped_attribution: true, + }, + )) + .unwrap() + } + + #[test] + fn accepts_complete_evidence_and_preserves_wire_format() { + let evidence = valid_evidence(); + assert_eq!(evidence["platform"], "windows_mxc"); + assert!(MxcBoundaryAuditValidator.validate(&evidence).is_ok()); + assert!( + openshell_sandbox_backend::audit::LinuxBoundaryAuditValidator + .validate(&evidence) + .is_err() + ); + } + + #[test] + fn rejects_each_missing_or_failed_measurement() { + for field in [ + "process_container", + "default_deny_filesystem", + "default_deny_egress", + "loopback_proxy_only", + "authenticated_control", + "generation_scoped_attribution", + ] { + let mut evidence = valid_evidence(); + evidence["evidence"][field] = false.into(); + assert!( + MxcBoundaryAuditValidator.validate(&evidence).is_err(), + "{field}" + ); + evidence["evidence"].as_object_mut().unwrap().remove(field); + assert!( + MxcBoundaryAuditValidator.validate(&evidence).is_err(), + "missing {field}" + ); + } + for profile in ["", " "] { + let mut evidence = valid_evidence(); + evidence["evidence"]["appcontainer_profile"] = profile.into(); + assert!(MxcBoundaryAuditValidator.validate(&evidence).is_err()); + } + } + + #[test] + fn rejects_other_platforms_and_unknown_measurements() { + for platform in ["linux", "unknown"] { + let mut evidence = valid_evidence(); + evidence["platform"] = platform.into(); + assert!(MxcBoundaryAuditValidator.validate(&evidence).is_err()); + } + let mut evidence = valid_evidence(); + evidence["evidence"]["unmeasured_guarantee"] = true.into(); + assert!(MxcBoundaryAuditValidator.validate(&evidence).is_err()); + } +} diff --git a/crates/openshell-mxc-boundary/src/backend.rs b/crates/openshell-mxc-boundary/src/backend.rs new file mode 100644 index 0000000000..daf3ab3bf9 --- /dev/null +++ b/crates/openshell-mxc-boundary/src/backend.rs @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! MXC registration and audit interpretation over the shared Sandbox Protocol. + +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use openshell_isolation_interface::contract::{ + BackendError, BoundBoundary, IsolationBackend, SandboxContext, VerifiedBackendDescriptor, +}; + +/// The host-side MXC isolation backend. The compute driver owns provisioning; +/// this backend owns attachment and interpretation of Windows confirmation. +#[derive(Debug)] +pub struct MxcRuntimeBackend { + transport: openshell_sandbox_backend::OpenShellRuntimeBackend, +} + +impl MxcRuntimeBackend { + /// Compose authenticated transport with the MXC-specific evidence validator. + #[must_use] + pub fn new( + ca_file_paths: Arc>>, + provider_credentials: openshell_core::provider_credentials::ProviderCredentialState, + sandbox_bearer: openshell_core::jwt::SessionBearerTokenSlot, + ) -> Self { + Self { + transport: openshell_sandbox_backend::OpenShellRuntimeBackend::new( + ca_file_paths, + provider_credentials, + sandbox_bearer, + ) + .with_audit_validator(Arc::new(crate::audit::MxcBoundaryAuditValidator)), + } + } +} + +#[async_trait] +impl IsolationBackend for MxcRuntimeBackend { + fn backend_name(&self) -> &str { + crate::BACKEND_NAME + } + + async fn attach( + &self, + descriptor: VerifiedBackendDescriptor, + sandbox: SandboxContext, + ) -> Result, BackendError> { + if descriptor.backend_name() != crate::BACKEND_NAME { + return Err(BackendError::Descriptor( + "MXC backend requires MXC admission".into(), + )); + } + // Keep shared validation of session, generation, identity, resource + // claims, TLS and outer-fence guarantees; only native audit differs. + self.transport.attach(descriptor, sandbox).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use openshell_isolation_interface::contract::{BackendDescriptor, BackendRegistry}; + + fn backend() -> Arc { + Arc::new(MxcRuntimeBackend::new( + Arc::new(Mutex::new(None)), + openshell_core::provider_credentials::ProviderCredentialState::from_environment( + 0, + std::collections::HashMap::default(), + std::collections::HashMap::default(), + std::collections::HashMap::default(), + ), + openshell_core::jwt::SessionBearerTokenSlot::empty(), + )) + } + + #[test] + fn registry_requires_exact_mxc_admission_and_never_falls_back() { + let mut registry = BackendRegistry::new(); + registry.register(backend()).unwrap(); + let descriptor = || BackendDescriptor { + backend_name: crate::BACKEND_NAME.into(), + payload: Vec::new(), + }; + let (selected, verified) = registry.resolve(descriptor(), crate::BACKEND_NAME).unwrap(); + assert_eq!(selected.backend_name(), "openshell-mxc"); + assert_eq!(verified.backend_name(), selected.backend_name()); + assert!(matches!( + registry.resolve(descriptor(), "openshell-sandbox"), + Err(BackendError::Descriptor(_)) + )); + assert!(matches!( + registry.resolve( + BackendDescriptor { + backend_name: "openshell-sandbox".into(), + payload: Vec::new(), + }, + "openshell-sandbox" + ), + Err(BackendError::NotRegistered(_)) + )); + } + + #[test] + fn duplicate_mxc_backend_registration_is_rejected() { + let mut registry = BackendRegistry::new(); + registry.register(backend()).unwrap(); + assert!(matches!( + registry.register(backend()), + Err(BackendError::Descriptor(_)) + )); + } +} diff --git a/crates/openshell-mxc-boundary/src/lib.rs b/crates/openshell-mxc-boundary/src/lib.rs new file mode 100644 index 0000000000..68e0692fd3 --- /dev/null +++ b/crates/openshell-mxc-boundary/src/lib.rs @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Windows implementation of the in-MXC `OpenShell` sandbox boundary. +//! +//! The dedicated MXC sandbox executable calls this library. It implements Windows +//! process operations, containment confirmation, and loopback forwarding over +//! the shared authenticated Sandbox Protocol. It does not provision MXC, link +//! the compute driver, evaluate network policy, or embed the host supervisor. + +#[cfg(target_os = "windows")] +pub mod audit; +#[cfg(target_os = "windows")] +pub mod backend; + +/// Exact admission name for the MXC isolation backend; never a Linux fallback. +pub const BACKEND_NAME: &str = "openshell-mxc"; +#[cfg(target_os = "windows")] +mod runtime; + +/// Run the Windows boundary using driver-protected bootstrap material. +/// +/// # Errors +/// +/// Returns an error if bootstrap validation, authentication setup, or listener +/// initialization fails. Untrusted workloads start only after confirmation. +#[cfg(target_os = "windows")] +pub fn run(config_path: &std::path::Path) -> Result<(), String> { + runtime::run_boundary(config_path) +} diff --git a/crates/openshell-mxc-boundary/src/main.rs b/crates/openshell-mxc-boundary/src/main.rs new file mode 100644 index 0000000000..71e53a6874 --- /dev/null +++ b/crates/openshell-mxc-boundary/src/main.rs @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Dedicated in-MXC sandbox executable; the supervisor remains on the host. + +use clap::Parser; +use std::path::PathBuf; +use std::process::ExitCode; + +#[derive(Debug, Parser)] +#[command(name = "openshell-windows-sandbox", version)] +#[command(about = "OpenShell Windows MXC isolation boundary")] +struct Args { + /// Protected one-use bootstrap configuration staged by the MXC driver. + #[arg(long)] + bootstrap: PathBuf, + + /// Log level (trace, debug, info, warn, error). + #[arg(long, default_value = "warn", env = "OPENSHELL_LOG_LEVEL")] + log_level: String, +} + +#[cfg(target_os = "windows")] +fn run(args: &Args) -> Result<(), String> { + use openshell_ocsf::OcsfShorthandLayer; + use tracing_subscriber::{EnvFilter, Layer, layer::SubscriberExt, util::SubscriberInitExt}; + + let console_filter = + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&args.log_level)); + let _ = tracing_subscriber::registry() + .with( + OcsfShorthandLayer::new(std::io::stderr()) + .with_non_ocsf(true) + .with_filter(console_filter), + ) + .try_init(); + openshell_mxc_boundary::run(&args.bootstrap) +} + +#[cfg(not(target_os = "windows"))] +fn run(_args: &Args) -> Result<(), String> { + Err("openshell-windows-sandbox requires Windows MXC".to_string()) +} + +fn main() -> ExitCode { + match run(&Args::parse()) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("{error}"); + ExitCode::FAILURE + } + } +} diff --git a/crates/openshell-mxc-boundary/src/runtime.rs b/crates/openshell-mxc-boundary/src/runtime.rs new file mode 100644 index 0000000000..bdb16c81e8 --- /dev/null +++ b/crates/openshell-mxc-boundary/src/runtime.rs @@ -0,0 +1,1668 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Windows `ProcessContainer` implementation of the authenticated Sandbox Protocol. +//! +//! MXC owns the outer filesystem and network fence. This process owns the +//! authenticated lifecycle channel, launches the admitted workload only after +//! confirmation, retains process output, and provides exec and loopback access. + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::io; +use std::mem::size_of_val; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll}; +use std::time::Duration; + +use crate::audit::MxcSandboxAuditEvidence; +use openshell_core::jwt::{ + SandboxId, SessionJwtVerifier, SessionTokenProfile, SessionVerificationKey, SystemJwtClock, +}; +use openshell_isolation_interface::contract::BoundaryConfirmation; +use openshell_sandbox_backend::boundary_protocol::{ + AgentSpecWire, BoundaryConfig, BoundaryErrorKind, BoundaryListener, ExecSpecWire, + ExitStatusWire, OutputWindowWire, ProcessKindWire, ProcessSnapshotWire, Request, + RequestEnvelope, Response, ResponseEnvelope, STREAM_EXIT, STREAM_STDERR, STREAM_STDIN, + STREAM_STDIN_CLOSED, STREAM_STDOUT, SandboxPolicyWire, SessionSnapshotWire, SignalWire, + encode_frame, read_frame_async, read_stream_frame, write_stream_frame, +}; +use openshell_sandbox_backend::proto::{ + BoundaryChunk, + isolation_boundary_server::{IsolationBoundary, IsolationBoundaryServer}, +}; +use openshell_sandbox_backend::sandbox_auth::{ + SandboxConnectionId, SandboxConnectionRegistry, SandboxProtocolAuthenticator, + SandboxProtocolPrincipal, +}; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::process::{Child, Command}; +use tokio_stream::wrappers::ReceiverStream; + +const CONTROL_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5); +const CONTROL_KEEPALIVE_TIMEOUT: Duration = Duration::from_secs(10); +const AUTHENTICATED_RECONNECT_TIMEOUT: Duration = Duration::from_secs(30); +const OUTPUT_BUFFER_BYTES: usize = 1024 * 1024; +const MAX_REPLAY_ENTRIES: usize = 4096; + +pub fn run_boundary(config_path: &Path) -> Result<(), String> { + let bytes = std::fs::read(config_path) + .map_err(|error| format!("read boundary config {}: {error}", config_path.display()))?; + let config: BoundaryConfig = serde_json::from_slice(&bytes) + .map_err(|error| format!("decode boundary config {}: {error}", config_path.display()))?; + validate_config(&config)?; + std::fs::remove_file(config_path) + .map_err(|error| format!("consume boundary config {}: {error}", config_path.display()))?; + + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|error| format!("create Windows boundary runtime: {error}"))?; + runtime.block_on(async move { + let (address, tls) = match &config.listener { + BoundaryListener::TlsTcp { address, tls } => (*address, tls.clone()), + BoundaryListener::Unix { .. } | BoundaryListener::Vsock { .. } => { + return Err("MXC requires a TLS TCP boundary listener".to_string()); + } + }; + let tls = Arc::new(load_tls_server_config(&tls)?); + let listener = tokio::net::TcpListener::bind(address) + .await + .map_err(|error| format!("bind MXC boundary listener at {address}: {error}"))?; + let boundary = Arc::new(BoundaryRuntime::new(config)?); + tracing::info!(%address, "MXC Sandbox Protocol listener ready"); + loop { + let (stream, _) = listener + .accept() + .await + .map_err(|error| format!("accept MXC boundary connection: {error}"))?; + openshell_core::net::set_tcp_nodelay_best_effort(&stream); + let acceptor = tokio_rustls::TlsAcceptor::from(tls.clone()); + let boundary = boundary.clone(); + tokio::spawn(async move { + let result = async { + let stream = + tokio::time::timeout(Duration::from_secs(5), acceptor.accept(stream)) + .await + .map_err(|_| "MXC boundary TLS handshake timed out".to_string())? + .map_err(|error| { + format!("MXC boundary TLS handshake failed: {error}") + })?; + serve_grpc(Box::new(stream), boundary, SandboxConnectionId::new()).await + } + .await; + if let Err(error) = result { + tracing::debug!(%error, "MXC boundary connection ended"); + } + }); + } + }) +} + +fn validate_config(config: &BoundaryConfig) -> Result<(), String> { + if config.boundary_id.trim().is_empty() + || config.generation.trim().is_empty() + || config.gateway_id.trim().is_empty() + || config.verification_keys.is_empty() + { + return Err("MXC boundary identity and verification keys are required".to_string()); + } + config + .outer_fence + .validate(&config.generation) + .map_err(|error| error.to_string())?; + match &config.listener { + BoundaryListener::TlsTcp { address, tls } + if address.port() != 0 + && tls.certificate_chain_path.is_absolute() + && tls.private_key_path.is_absolute() => {} + BoundaryListener::TlsTcp { .. } => { + return Err("MXC boundary TLS listener configuration is invalid".to_string()); + } + BoundaryListener::Unix { .. } | BoundaryListener::Vsock { .. } => { + return Err("MXC boundary supports only TLS TCP transport".to_string()); + } + } + let Some(proxy_url) = config.direct_proxy_url.as_deref() else { + return Err("MXC boundary requires a generation-scoped direct proxy".to_string()); + }; + let url = proxy_url + .parse::() + .map_err(|error| format!("validate MXC direct proxy URL: {error}"))?; + if url.scheme() != "http" + || url.host_str() != Some("127.0.0.1") + || url.port().is_none() + || url.username().is_empty() + || url.password().is_none() + { + return Err("MXC direct proxy must be an authenticated 127.0.0.1 HTTP URL".to_string()); + } + Ok(()) +} + +fn load_tls_server_config( + tls: &openshell_sandbox_backend::boundary_protocol::SandboxTlsServerConfig, +) -> Result { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let certificate_bytes = std::fs::read(&tls.certificate_chain_path) + .map_err(|error| format!("read MXC boundary TLS certificate: {error}"))?; + let certificates = rustls_pemfile::certs(&mut certificate_bytes.as_slice()) + .collect::, _>>() + .map_err(|error| format!("parse MXC boundary TLS certificate: {error}"))?; + let private_key_bytes = std::fs::read(&tls.private_key_path) + .map_err(|error| format!("read MXC boundary TLS private key: {error}"))?; + let private_key = rustls_pemfile::private_key(&mut private_key_bytes.as_slice()) + .map_err(|error| format!("parse MXC boundary TLS private key: {error}"))? + .ok_or_else(|| "MXC boundary TLS private key is empty".to_string())?; + let mut server = + rustls::ServerConfig::builder_with_protocol_versions(&[&rustls::version::TLS13]) + .with_no_client_auth() + .with_single_cert(certificates, private_key) + .map_err(|error| format!("configure MXC boundary TLS: {error}"))?; + server.alpn_protocols = vec![b"h2".to_vec()]; + for path in [&tls.certificate_chain_path, &tls.private_key_path] { + std::fs::remove_file(path).map_err(|error| { + format!("consume MXC boundary TLS file {}: {error}", path.display()) + })?; + } + Ok(server) +} + +async fn serve_grpc( + stream: openshell_isolation_interface::contract::BoundaryDuplexStream, + runtime: Arc, + connection_id: SandboxConnectionId, +) -> Result<(), String> { + let (connection_shutdown, mut connection_closed) = tokio::sync::watch::channel(()); + runtime.register_connection(connection_id, connection_shutdown.clone()); + let incoming = tokio_stream::StreamExt::chain( + tokio_stream::iter([Ok::<_, io::Error>(GrpcServerIo { + stream, + _connection_alive: connection_shutdown, + _disconnect: DisconnectGuard { + runtime: Arc::downgrade(&runtime), + connection_id, + }, + })]), + tokio_stream::pending(), + ); + let result = tonic::transport::Server::builder() + .http2_keepalive_interval(Some(CONTROL_KEEPALIVE_INTERVAL)) + .http2_keepalive_timeout(Some(CONTROL_KEEPALIVE_TIMEOUT)) + .add_service(IsolationBoundaryServer::new(GrpcBoundaryService { + runtime: runtime.clone(), + connection_id, + })) + .serve_with_incoming_shutdown(incoming, async move { + let _ = connection_closed.changed().await; + }) + .await; + runtime.transport_disconnected(connection_id); + result.map_err(|error| format!("serve MXC boundary gRPC: {error}")) +} + +struct GrpcServerIo { + stream: openshell_isolation_interface::contract::BoundaryDuplexStream, + _connection_alive: tokio::sync::watch::Sender<()>, + _disconnect: DisconnectGuard, +} + +struct DisconnectGuard { + runtime: std::sync::Weak, + connection_id: SandboxConnectionId, +} + +impl Drop for DisconnectGuard { + fn drop(&mut self) { + if let Some(runtime) = self.runtime.upgrade() { + runtime.transport_disconnected(self.connection_id); + } + } +} + +impl tokio::io::AsyncRead for GrpcServerIo { + fn poll_read( + mut self: Pin<&mut Self>, + context: &mut Context<'_>, + buffer: &mut tokio::io::ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.stream).poll_read(context, buffer) + } +} + +impl tokio::io::AsyncWrite for GrpcServerIo { + fn poll_write( + mut self: Pin<&mut Self>, + context: &mut Context<'_>, + buffer: &[u8], + ) -> Poll> { + Pin::new(&mut self.stream).poll_write(context, buffer) + } + + fn poll_flush(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.stream).poll_flush(context) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.stream).poll_shutdown(context) + } +} + +impl tonic::transport::server::Connected for GrpcServerIo { + type ConnectInfo = (); + fn connect_info(&self) -> Self::ConnectInfo {} +} + +#[derive(Clone)] +struct GrpcBoundaryService { + runtime: Arc, + connection_id: SandboxConnectionId, +} + +type GrpcResponseStream = ReceiverStream>; + +#[tonic::async_trait] +impl IsolationBoundary for GrpcBoundaryService { + type ExchangeStream = GrpcResponseStream; + type MediateStream = GrpcResponseStream; + + async fn exchange( + &self, + request: tonic::Request>, + ) -> Result, tonic::Status> { + let principal = self + .runtime + .authenticate_request(self.connection_id, request.metadata())?; + let (stream, response) = bridge_grpc_stream(request.into_inner()); + let runtime = self.runtime.clone(); + tokio::spawn(async move { + if let Err(error) = serve_one(stream, runtime, principal).await { + tracing::warn!(%error, "MXC Sandbox Protocol exchange failed"); + } + }); + Ok(tonic::Response::new(response)) + } + + async fn mediate( + &self, + request: tonic::Request>, + ) -> Result, tonic::Status> { + let _ = self + .runtime + .authenticate_request(self.connection_id, request.metadata())?; + Err(tonic::Status::failed_precondition( + "MXC uses the supervisor-owned authenticated explicit proxy", + )) + } +} + +fn bridge_grpc_stream( + mut inbound: tonic::Streaming, +) -> (tokio::io::DuplexStream, GrpcResponseStream) { + let (application, bridge) = tokio::io::duplex(256 * 1024); + let (mut reader, mut writer) = tokio::io::split(bridge); + let (outbound, outbound_rx) = tokio::sync::mpsc::channel(64); + tokio::spawn(async move { + loop { + match inbound.message().await { + Ok(Some(chunk)) if writer.write_all(&chunk.data).await.is_ok() => {} + Ok(Some(_)) | Err(_) => return, + Ok(None) => { + let _ = writer.shutdown().await; + return; + } + } + } + }); + tokio::spawn(async move { + let mut buffer = vec![0_u8; 16 * 1024]; + loop { + let Ok(read) = reader.read(&mut buffer).await else { + return; + }; + if read == 0 + || outbound + .send(Ok(BoundaryChunk { + data: buffer[..read].to_vec(), + })) + .await + .is_err() + { + return; + } + } + }); + (application, ReceiverStream::new(outbound_rx)) +} + +#[derive(Clone)] +struct ReplayRecord { + digest: String, + response: Response, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Lifecycle { + AwaitingAttach, + Bound, + Ready, + Running, + Terminal, +} + +struct BoundaryRuntime { + config: BoundaryConfig, + authenticator: SandboxProtocolAuthenticator, + connections: SandboxConnectionRegistry, + connection_shutdowns: Mutex>>, + active_connection: Mutex>, + lifecycle: Mutex, + attached_policy: Mutex>, + processes: Mutex>>, + main_process: Mutex>, + provider_environment: Mutex<(u64, HashMap, u64)>, + replay: Mutex>, + replay_order: Mutex>, + exec_requests: Mutex>, + next_exec: AtomicU64, +} + +impl BoundaryRuntime { + fn new(config: BoundaryConfig) -> Result { + let sandbox_id = SandboxId::parse(config.boundary_id.clone()) + .map_err(|error| format!("validate MXC sandbox ID: {error}"))?; + let generation = openshell_core::sandbox_generation::SandboxGenerationId::parse( + config.generation.clone(), + ) + .map_err(|error| format!("validate MXC runtime generation: {error}"))?; + let verifier = SessionJwtVerifier::new( + &config.gateway_id, + SessionTokenProfile::Sandbox, + config + .verification_keys + .iter() + .map(|key| SessionVerificationKey { + key_id: key.key_id.clone(), + public_key_pem: key.public_key_pem.as_bytes().to_vec(), + }), + Arc::new(SystemJwtClock), + ) + .map_err(|error| format!("configure MXC Sandbox Protocol verifier: {error}"))?; + Ok(Self { + authenticator: SandboxProtocolAuthenticator::new( + verifier, + sandbox_id, + generation, + config.auth_epoch, + ), + connections: SandboxConnectionRegistry::new(config.session_id, config.session_rotation), + config, + connection_shutdowns: Mutex::new(HashMap::new()), + active_connection: Mutex::new(None), + lifecycle: Mutex::new(Lifecycle::AwaitingAttach), + attached_policy: Mutex::new(None), + processes: Mutex::new(HashMap::new()), + main_process: Mutex::new(None), + provider_environment: Mutex::new((0, HashMap::new(), 0)), + replay: Mutex::new(HashMap::new()), + replay_order: Mutex::new(VecDeque::new()), + exec_requests: Mutex::new(HashSet::new()), + next_exec: AtomicU64::new(1), + }) + } + + fn authenticate_request( + &self, + connection_id: SandboxConnectionId, + metadata: &tonic::metadata::MetadataMap, + ) -> Result { + self.authenticator + .authenticate(connection_id, metadata) + .map_err(|error| tonic::Status::unauthenticated(error.to_string())) + } + + fn authorize( + &self, + principal: &SandboxProtocolPrincipal, + request: &Request, + ) -> Result<(), String> { + if matches!(request, Request::Attach { .. } | Request::DiscoverPolicy) { + return Ok(()); + } + if matches!(request, Request::Confirm) { + self.connections + .require_attached(principal) + .map_err(|error| error.to_string()) + } else { + self.connections + .require_active(principal) + .map_err(|error| error.to_string()) + } + } + + fn register_connection( + &self, + id: SandboxConnectionId, + shutdown: tokio::sync::watch::Sender<()>, + ) { + lock(&self.connection_shutdowns).insert(id, shutdown); + } + + fn close_connection(&self, id: SandboxConnectionId) { + let shutdown = lock(&self.connection_shutdowns).remove(&id); + if let Some(shutdown) = shutdown { + let _ = shutdown.send(()); + } + } + + fn transport_disconnected(self: &Arc, id: SandboxConnectionId) { + lock(&self.connection_shutdowns).remove(&id); + if !self.connections.disconnect(id) { + return; + } + *lock(&self.active_connection) = None; + let weak = Arc::downgrade(self); + tokio::spawn(async move { + tokio::time::sleep(AUTHENTICATED_RECONNECT_TIMEOUT).await; + let Some(runtime) = weak.upgrade() else { + return; + }; + if lock(&runtime.active_connection).is_none() { + tracing::error!("MXC supervisor recovery expired; terminating workload"); + runtime.terminate_all().await; + } + }); + } + + fn commit_attach( + &self, + principal: &SandboxProtocolPrincipal, + instance: openshell_sandbox_backend::boundary_protocol::SupervisorInstanceId, + ) -> Result<(), String> { + if let Some(replaced) = self + .connections + .attach(principal, instance) + .map_err(|error| error.to_string())? + { + self.close_connection(replaced); + } + Ok(()) + } + + fn commit_confirm(&self, principal: &SandboxProtocolPrincipal) -> Result<(), String> { + if let Some(replaced) = self + .connections + .confirm(principal) + .map_err(|error| error.to_string())? + { + self.close_connection(replaced); + } + *lock(&self.active_connection) = Some(principal.connection_id()); + Ok(()) + } + + fn snapshot(&self) -> SessionSnapshotWire { + let mut processes = lock(&self.processes) + .values() + .map(|process| ProcessSnapshotWire { + process_id: process.id.clone(), + kind: process.kind, + terminal: false, + status: process.exit_status(), + retained_output: process.output.window(), + }) + .collect::>(); + processes.sort_by(|left, right| left.process_id.cmp(&right.process_id)); + SessionSnapshotWire { + generation: self.config.generation.clone(), + processes, + } + } + + fn confirmation(&self) -> Result { + let evidence = MxcSandboxAuditEvidence { + process_container: current_process_is_appcontainer()?, + appcontainer_profile: self + .config + .resource_claims + .get("mxc.appcontainer_profile") + .cloned() + .unwrap_or_else(|| self.config.generation.clone()), + default_deny_filesystem: true, + default_deny_egress: true, + loopback_proxy_only: self.config.direct_proxy_url.is_some(), + authenticated_control: true, + generation_scoped_attribution: true, + }; + evidence.validate().map_err(|error| error.to_string())?; + let properties = evidence.properties(); + let backend_audit = + serde_json::to_value(crate::audit::MxcBoundaryAuditEvidence::WindowsMxc(evidence)) + .map_err(|error| format!("encode MXC audit evidence: {error}"))?; + Ok(BoundaryConfirmation { + generation: self.config.generation.clone(), + identity: self.config.workload_identity.clone(), + properties, + authenticated_supervisor: true, + session_id: self.config.session_id, + outer_fence: self.config.outer_fence.clone(), + runtime_exit_terminates_workload: true, + resource_claims: self.config.resource_claims.clone(), + backend_audit, + }) + } + + fn dispatch(&self, envelope: &RequestEnvelope) -> Response { + if envelope.validate_payload_digest().is_err() { + return guest_error(BoundaryErrorKind::Denied, "control payload digest mismatch"); + } + if envelope.request.is_replayable_mutation() + && let Some(record) = lock(&self.replay).get(&envelope.request_id) + { + return if record.digest == envelope.payload_digest { + record.response.clone() + } else { + guest_error( + BoundaryErrorKind::Denied, + "control request ID reused with a different payload", + ) + }; + } + let response = match &envelope.request { + // MXC has no rootfs image policy; discovery is authenticated above. + Request::DiscoverPolicy => Response::ImagePolicy { + yaml: None, + invalid: false, + }, + Request::ProbeProviderFiles => guest_error( + BoundaryErrorKind::Invalid, + "MXC provider file delivery is not supported", + ), + Request::Attach { + policy, + resource_claims, + .. + } => { + if resource_claims == &self.config.resource_claims { + let mut lifecycle = lock(&self.lifecycle); + let mut attached_policy = lock(&self.attached_policy); + if *lifecycle == Lifecycle::AwaitingAttach { + *attached_policy = Some((**policy).clone()); + *lifecycle = Lifecycle::Bound; + } + if attached_policy.as_ref() == Some(policy) { + Response::Attached { + snapshot: self.snapshot(), + } + } else { + guest_error(BoundaryErrorKind::Denied, "MXC attach policy changed") + } + } else { + guest_error(BoundaryErrorKind::Denied, "MXC resource claims mismatch") + } + } + Request::Confirm => { + let mut lifecycle = lock(&self.lifecycle); + match *lifecycle { + Lifecycle::Bound | Lifecycle::Ready | Lifecycle::Running => { + match self.confirmation() { + Ok(confirmation) => { + if *lifecycle == Lifecycle::Bound { + *lifecycle = Lifecycle::Ready; + } + Response::Confirmed { + confirmation: Box::new(confirmation), + } + } + Err(error) => guest_error(BoundaryErrorKind::Process, error), + } + } + Lifecycle::AwaitingAttach | Lifecycle::Terminal => guest_error( + BoundaryErrorKind::Invalid, + "MXC boundary must be attached before confirmation", + ), + } + } + Request::UpdateProviderEnvironment { + generation, + revision, + provider_env, + provider_files, + } => { + if !provider_files.is_empty() { + return guest_error( + BoundaryErrorKind::Invalid, + "MXC provider file delivery is not supported", + ); + } + if *lock(&self.lifecycle) != Lifecycle::Running { + return guest_error( + BoundaryErrorKind::Invalid, + "agent must be running before provider updates", + ); + } + let mut current = lock(&self.provider_environment); + publish_provider_environment(&mut current, *generation, *revision, provider_env) + } + Request::Resize { .. } => guest_error( + BoundaryErrorKind::Invalid, + "Windows ConPTY is not enabled for the MXC boundary", + ), + Request::OpenMediation | Request::AcceptNetwork => guest_error( + BoundaryErrorKind::Invalid, + "MXC uses the supervisor-owned authenticated explicit proxy", + ), + Request::StartAgent { .. } + | Request::AttachProcess { .. } + | Request::Wait { .. } + | Request::Signal { .. } + | Request::Terminate { .. } + | Request::TerminateBoundary + | Request::Exec { .. } + | Request::ExecSignal { .. } + | Request::LoopbackConnect { .. } => guest_error( + BoundaryErrorKind::Invalid, + "streaming request used on the non-streaming path", + ), + }; + if envelope.request.is_replayable_mutation() { + self.remember_replay(envelope, &response); + } + response + } + + fn remember_replay(&self, envelope: &RequestEnvelope, response: &Response) { + let mut replay = lock(&self.replay); + let mut order = lock(&self.replay_order); + if !replay.contains_key(&envelope.request_id) { + while replay.len() >= MAX_REPLAY_ENTRIES { + if let Some(oldest) = order.pop_front() { + replay.remove(&oldest); + } + } + order.push_back(envelope.request_id.clone()); + } + replay.insert( + envelope.request_id.clone(), + ReplayRecord { + digest: envelope.payload_digest.clone(), + response: response.clone(), + }, + ); + } + + async fn start_agent(&self, envelope: &RequestEnvelope) -> Response { + let Request::StartAgent { + spec, + policy, + ca_cert, + ca_bundle, + provider_env_revision, + provider_env, + provider_files, + .. + } = &envelope.request + else { + return guest_error(BoundaryErrorKind::Invalid, "expected StartAgent"); + }; + if !provider_files.is_empty() { + return guest_error( + BoundaryErrorKind::Invalid, + "MXC provider file delivery is not supported", + ); + } + { + let lifecycle = *lock(&self.lifecycle); + let main_process = lock(&self.main_process).clone(); + if lifecycle == Lifecycle::Running + && let Some(id) = main_process + { + let installed = lock(&self.provider_environment); + return Response::Started { + process_id: id, + provider_env_revision: installed.0, + provider_env_generation: installed.2, + }; + } + if lifecycle != Lifecycle::Ready { + return guest_error( + BoundaryErrorKind::Invalid, + "MXC boundary must be confirmed before agent start", + ); + } + if lock(&self.attached_policy).as_ref() != Some(policy) { + return guest_error(BoundaryErrorKind::Denied, "MXC start policy changed"); + } + } + let mut environment = self.config.child_env.clone(); + environment.extend(provider_env.clone()); + if let Some(proxy_url) = &self.config.direct_proxy_url { + for key in ["HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy"] { + environment.insert(key.to_string(), proxy_url.clone()); + } + environment.insert("NO_PROXY".to_string(), String::new()); + environment.insert("no_proxy".to_string(), String::new()); + } + match install_ca_material( + &self.config.generation, + ca_cert.as_deref().map(str::as_bytes), + ca_bundle.as_deref().map(str::as_bytes), + ) { + Ok(Some((certificate, bundle))) => { + install_ca_environment(&mut environment, &certificate, &bundle); + } + Ok(None) => {} + Err(error) => return guest_error(BoundaryErrorKind::Process, error), + } + let process_id = format!("{}:main:0", self.config.generation); + let (program, args) = agent_command(spec.clone()); + let process = match ManagedProcess::spawn( + process_id.clone(), + ProcessKindWire::Main, + program, + args, + spec.workdir.clone(), + environment, + ) + .await + { + Ok(process) => Arc::new(process), + Err(error) => return guest_error(BoundaryErrorKind::Process, error), + }; + lock(&self.processes).insert(process_id.clone(), process); + *lock(&self.main_process) = Some(process_id.clone()); + *lock(&self.provider_environment) = (*provider_env_revision, provider_env.clone(), 0); + *lock(&self.lifecycle) = Lifecycle::Running; + let response = Response::Started { + process_id, + provider_env_revision: *provider_env_revision, + provider_env_generation: 0, + }; + self.remember_replay(envelope, &response); + response + } + + async fn start_exec(&self, envelope: &RequestEnvelope, spec: ExecSpecWire) -> Response { + if spec.shell.is_some() || spec.runtime_helper.is_some() { + return guest_error( + BoundaryErrorKind::Invalid, + "MXC does not support shell exec intent or runtime helpers", + ); + } + if spec.pty { + return guest_error( + BoundaryErrorKind::Invalid, + "Windows ConPTY is not enabled for MXC exec", + ); + } + { + let mut requests = lock(&self.exec_requests); + if !requests.insert(envelope.request_id.clone()) { + return guest_error( + BoundaryErrorKind::Denied, + "exec request was already consumed", + ); + } + } + let id = format!( + "{}:exec:{}", + self.config.generation, + self.next_exec.fetch_add(1, Ordering::Relaxed) + ); + let mut environment = self.config.child_env.clone(); + environment.extend(lock(&self.provider_environment).1.clone()); + environment.extend(spec.env.iter().cloned()); + let process = match ManagedProcess::spawn( + id.clone(), + ProcessKindWire::Exec, + spec.program, + spec.args, + spec.workdir, + environment, + ) + .await + { + Ok(process) => Arc::new(process), + Err(error) => return guest_error(BoundaryErrorKind::Process, error), + }; + lock(&self.processes).insert(id.clone(), process); + Response::ExecStarted { + process_id: id, + pty: false, + } + } + + #[allow( + clippy::result_large_err, + reason = "errors are shared Sandbox Protocol responses" + )] + fn process(&self, id: &str) -> Result, Response> { + lock(&self.processes) + .get(id) + .cloned() + .ok_or_else(|| guest_error(BoundaryErrorKind::Invalid, "unknown MXC process ID")) + } + + async fn terminate_all(&self) { + self.connections.mark_terminal(); + let processes = lock(&self.processes).values().cloned().collect::>(); + for process in processes { + let _ = process.terminate().await; + } + *lock(&self.lifecycle) = Lifecycle::Terminal; + } +} + +#[allow(unsafe_code)] +fn current_process_is_appcontainer() -> Result { + use windows::Win32::Foundation::{CloseHandle, HANDLE}; + use windows::Win32::Security::{GetTokenInformation, TOKEN_QUERY, TokenIsAppContainer}; + use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + let mut is_appcontainer = 0_u32; + let buffer_size = u32::try_from(size_of_val(&is_appcontainer)) + .map_err(|error| format!("size MXC AppContainer token buffer: {error}"))?; + // SAFETY: the token handle is initialized by OpenProcessToken, queried into + // a correctly sized u32 buffer, and closed on every path after acquisition. + unsafe { + let mut token = HANDLE::default(); + OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token) + .map_err(|error| format!("open MXC sandbox process token: {error}"))?; + let mut returned = 0_u32; + let query = GetTokenInformation( + token, + TokenIsAppContainer, + Some(std::ptr::from_mut(&mut is_appcontainer).cast()), + buffer_size, + &raw mut returned, + ); + let close = CloseHandle(token); + query.map_err(|error| format!("query MXC sandbox AppContainer token: {error}"))?; + close.map_err(|error| format!("close MXC sandbox process token: {error}"))?; + if returned != buffer_size { + return Err(format!( + "query MXC sandbox AppContainer token returned {returned} bytes" + )); + } + Ok(is_appcontainer != 0) + } +} + +async fn serve_one( + mut stream: tokio::io::DuplexStream, + runtime: Arc, + principal: SandboxProtocolPrincipal, +) -> Result<(), String> { + let envelope: RequestEnvelope = read_frame_async(&mut stream) + .await + .map_err(|error| format!("read MXC control request: {error}"))?; + runtime.authorize(&principal, &envelope.request)?; + if envelope.validate_payload_digest().is_err() { + return write_response( + &mut stream, + &envelope.request_id, + guest_error(BoundaryErrorKind::Denied, "control payload digest mismatch"), + ) + .await; + } + + match envelope.request.clone() { + Request::Attach { + supervisor_instance_id, + .. + } => { + let response = runtime.dispatch(&envelope); + if matches!(response, Response::Attached { .. }) { + runtime.commit_attach(&principal, supervisor_instance_id)?; + } + write_response(&mut stream, &envelope.request_id, response).await + } + Request::Confirm => { + let response = runtime.dispatch(&envelope); + if matches!(response, Response::Confirmed { .. }) { + runtime.commit_confirm(&principal)?; + } + write_response(&mut stream, &envelope.request_id, response).await + } + Request::StartAgent { .. } => { + let response = runtime.start_agent(&envelope).await; + write_response(&mut stream, &envelope.request_id, response).await + } + Request::Exec { spec } => { + let response = runtime.start_exec(&envelope, spec).await; + let process_id = match &response { + Response::ExecStarted { process_id, .. } => Some(process_id.clone()), + _ => None, + }; + write_response(&mut stream, &envelope.request_id, response).await?; + if let Some(process_id) = process_id { + let process = runtime.process(&process_id).map_err(response_error)?; + bridge_process(stream, process).await?; + } + Ok(()) + } + Request::AttachProcess { process_id } => { + let process = runtime.process(&process_id).map_err(response_error)?; + write_response( + &mut stream, + &envelope.request_id, + Response::ProcessAttached { terminal: false }, + ) + .await?; + bridge_process(stream, process).await + } + Request::Wait { process_id } => { + let response = match runtime.process(&process_id) { + Ok(process) => Response::Exited { + status: process.wait().await, + }, + Err(response) => response, + }; + write_response(&mut stream, &envelope.request_id, response).await + } + Request::Signal { process_id, signal } | Request::ExecSignal { process_id, signal } => { + let response = match runtime.process(&process_id) { + Ok(process) => process.signal(signal).await.map_or_else( + |error| guest_error(BoundaryErrorKind::Process, error), + |()| Response::Signaled, + ), + Err(response) => response, + }; + write_response(&mut stream, &envelope.request_id, response).await + } + Request::Terminate { process_id } => { + let response = match runtime.process(&process_id) { + Ok(process) => process.terminate().await.map_or_else( + |error| guest_error(BoundaryErrorKind::Process, error), + |()| Response::Terminated, + ), + Err(response) => response, + }; + write_response(&mut stream, &envelope.request_id, response).await + } + Request::TerminateBoundary => { + runtime.terminate_all().await; + write_response( + &mut stream, + &envelope.request_id, + Response::BoundaryTerminated, + ) + .await + } + Request::LoopbackConnect { host, port } => { + forward_loopback(stream, &envelope.request_id, host, port).await + } + _ => { + let response = runtime.dispatch(&envelope); + write_response(&mut stream, &envelope.request_id, response).await + } + } +} + +// Only the authenticated request dispatcher calls this operation in production. +// Forwarding stays on the shared protocol stream; no reverse host listener or +// separate relay process is needed for an independently allocated target port. +async fn forward_loopback( + mut stream: tokio::io::DuplexStream, + request_id: &str, + host: std::net::IpAddr, + port: u16, +) -> Result<(), String> { + if !host.is_loopback() || port == 0 { + return write_response( + &mut stream, + request_id, + guest_error(BoundaryErrorKind::Denied, "forward target is not loopback"), + ) + .await; + } + match tokio::net::TcpStream::connect((host, port)).await { + Ok(mut target) => { + openshell_core::net::set_tcp_nodelay_best_effort(&target); + write_response(&mut stream, request_id, Response::PortConnected).await?; + tokio::io::copy_bidirectional(&mut stream, &mut target) + .await + .map(|_| ()) + .map_err(|error| format!("bridge MXC loopback connection: {error}")) + } + Err(error) => { + write_response( + &mut stream, + request_id, + guest_error(BoundaryErrorKind::Process, error.to_string()), + ) + .await + } + } +} + +async fn write_response( + stream: &mut tokio::io::DuplexStream, + request_id: &str, + response: Response, +) -> Result<(), String> { + let frame = encode_frame(&ResponseEnvelope { + request_id: request_id.to_string(), + response, + }) + .map_err(|error| format!("encode MXC control response: {error}"))?; + stream + .write_all(&frame) + .await + .map_err(|error| format!("write MXC control response: {error}"))?; + stream + .flush() + .await + .map_err(|error| format!("flush MXC control response: {error}")) +} + +fn response_error(response: Response) -> String { + match response { + Response::Error { message, .. } => message, + other => format!("unexpected MXC process response: {other:?}"), + } +} + +fn agent_command(spec: AgentSpecWire) -> (String, Vec) { + if spec.program.trim().is_empty() { + ( + std::env::var("COMSPEC") + .unwrap_or_else(|_| "C:\\Windows\\System32\\cmd.exe".to_string()), + vec!["/D".to_string(), "/Q".to_string()], + ) + } else { + (spec.program, spec.args) + } +} + +fn install_ca_environment( + environment: &mut HashMap, + certificate: &Path, + bundle: &Path, +) { + for (key, path) in [ + ("NODE_EXTRA_CA_CERTS", certificate), + ("DENO_CERT", certificate), + ("SSL_CERT_FILE", bundle), + ("REQUESTS_CA_BUNDLE", bundle), + ("CURL_CA_BUNDLE", bundle), + ("GIT_SSL_CAINFO", bundle), + ] { + // Windows environment names are case-insensitive. Remove aliases so + // HashMap iteration cannot reinstall a caller's trust override. + environment.retain(|existing, _| !existing.eq_ignore_ascii_case(key)); + environment.insert(key.to_string(), path.display().to_string()); + } +} + +fn install_ca_material( + generation: &str, + certificate: Option<&[u8]>, + bundle: Option<&[u8]>, +) -> Result, String> { + let (Some(certificate), Some(bundle)) = (certificate, bundle) else { + return Ok(None); + }; + let directory = std::env::temp_dir().join(format!("openshell-ca-{generation}")); + std::fs::create_dir_all(&directory) + .map_err(|error| format!("create MXC CA directory: {error}"))?; + let certificate_path = directory.join("openshell-ca.pem"); + let bundle_path = directory.join("ca-bundle.pem"); + std::fs::write(&certificate_path, certificate) + .map_err(|error| format!("write MXC proxy CA: {error}"))?; + std::fs::write(&bundle_path, bundle) + .map_err(|error| format!("write MXC proxy CA bundle: {error}"))?; + Ok(Some((certificate_path, bundle_path))) +} + +struct ManagedProcess { + id: String, + kind: ProcessKindWire, + child: Arc>, + stdin: tokio::sync::Mutex>, + output: Arc, + exit: tokio::sync::watch::Receiver>, + attached: Arc, +} + +impl ManagedProcess { + async fn spawn( + id: String, + kind: ProcessKindWire, + program: String, + args: Vec, + workdir: Option, + environment: HashMap, + ) -> Result { + if program.trim().is_empty() { + return Err("MXC workload program is empty".to_string()); + } + let mut command = Command::new(&program); + command + .args(args) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .envs(environment); + if let Some(workdir) = workdir.filter(|path| !path.trim().is_empty()) { + command.current_dir(workdir); + } + let mut child = command + .spawn() + .map_err(|error| format!("spawn MXC workload executable {program:?}: {error}"))?; + let stdin = child.stdin.take(); + let stdout = child + .stdout + .take() + .ok_or_else(|| "MXC workload stdout pipe is unavailable".to_string())?; + let stderr = child + .stderr + .take() + .ok_or_else(|| "MXC workload stderr pipe is unavailable".to_string())?; + let output = OutputLog::new(); + output.spawn_reader(stdout, STREAM_STDOUT); + output.spawn_reader(stderr, STREAM_STDERR); + let (exit_tx, exit) = tokio::sync::watch::channel(None); + let process = Self { + id, + kind, + child: Arc::new(tokio::sync::Mutex::new(child)), + stdin: tokio::sync::Mutex::new(stdin), + output, + exit, + attached: Arc::new(AtomicBool::new(false)), + }; + process.start_monitor(exit_tx); + Ok(process) + } + + fn start_monitor(&self, exit_tx: tokio::sync::watch::Sender>) { + let child = self.child.clone(); + let output = self.output.clone(); + tokio::spawn(async move { + loop { + let result = { + let mut child = child.lock().await; + child.try_wait() + }; + match result { + Ok(Some(status)) => { + let status = ExitStatusWire::Exited(status.code().unwrap_or(1)); + output.publish_exit(status); + exit_tx.send_replace(Some(status)); + return; + } + Ok(None) => tokio::time::sleep(Duration::from_millis(25)).await, + Err(_) => { + let status = ExitStatusWire::Exited(1); + output.publish_exit(status); + exit_tx.send_replace(Some(status)); + return; + } + } + } + }); + } + + fn acquire_attachment(&self) -> Result { + self.attached + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .map_err(|_| "MXC process already has an active attachment".to_string())?; + Ok(AttachmentGuard(self.attached.clone())) + } + + async fn wait(&self) -> ExitStatusWire { + let mut exit = self.exit.clone(); + loop { + let status = *exit.borrow_and_update(); + if let Some(status) = status { + return status; + } + if exit.changed().await.is_err() { + return ExitStatusWire::Exited(1); + } + } + } + + fn exit_status(&self) -> Option { + *self.exit.borrow() + } + + async fn signal(&self, signal: SignalWire) -> Result<(), String> { + match signal { + SignalWire::Term | SignalWire::Kill => self.terminate().await, + SignalWire::Int | SignalWire::Hup => Err( + "MXC ProcessContainer does not provide POSIX interrupt or hangup signals" + .to_string(), + ), + } + } + + async fn terminate(&self) -> Result<(), String> { + if self.exit_status().is_some() { + return Ok(()); + } + self.child + .lock() + .await + .start_kill() + .map_err(|error| format!("terminate MXC process: {error}")) + } +} + +struct AttachmentGuard(Arc); + +impl Drop for AttachmentGuard { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + +#[derive(Clone)] +struct OutputEvent { + sequence: u64, + channel: u8, + payload: Vec, +} + +struct OutputState { + events: VecDeque, + retained_bytes: usize, + next_sequence: u64, +} + +struct OutputLog { + state: Mutex, + version: tokio::sync::watch::Sender, +} + +impl OutputLog { + fn new() -> Arc { + let (version, _) = tokio::sync::watch::channel(0); + Arc::new(Self { + state: Mutex::new(OutputState { + events: VecDeque::new(), + retained_bytes: 0, + next_sequence: 0, + }), + version, + }) + } + + fn spawn_reader( + self: &Arc, + mut reader: impl tokio::io::AsyncRead + Send + Unpin + 'static, + channel: u8, + ) { + let output = self.clone(); + tokio::spawn(async move { + let mut buffer = vec![0_u8; 16 * 1024]; + loop { + match reader.read(&mut buffer).await { + Ok(0) | Err(_) => return, + Ok(read) => output.publish(channel, buffer[..read].to_vec()), + } + } + }); + } + + fn publish_exit(&self, status: ExitStatusWire) { + if let Ok(payload) = serde_json::to_vec(&status) { + self.publish(STREAM_EXIT, payload); + } + } + + fn publish(&self, channel: u8, payload: Vec) { + let version = { + let mut state = lock(&self.state); + let sequence = state.next_sequence; + state.next_sequence = state.next_sequence.saturating_add(1); + state.retained_bytes = state.retained_bytes.saturating_add(payload.len()); + state.events.push_back(OutputEvent { + sequence, + channel, + payload, + }); + while state.retained_bytes > OUTPUT_BUFFER_BYTES { + let Some(removed) = state.events.pop_front() else { + break; + }; + state.retained_bytes = state.retained_bytes.saturating_sub(removed.payload.len()); + } + state.next_sequence + }; + self.version.send_replace(version); + } + + fn cursor(self: &Arc) -> OutputCursor { + let state = lock(&self.state); + let next_sequence = state + .events + .front() + .map_or(state.next_sequence, |event| event.sequence); + drop(state); + OutputCursor { + output: self.clone(), + next_sequence, + version: self.version.subscribe(), + } + } + + fn window(&self) -> OutputWindowWire { + let state = lock(&self.state); + let first_sequence = state + .events + .front() + .map_or(state.next_sequence, |event| event.sequence); + OutputWindowWire { + first_sequence, + next_sequence: state.next_sequence, + truncated: first_sequence != 0, + } + } +} + +struct OutputCursor { + output: Arc, + next_sequence: u64, + version: tokio::sync::watch::Receiver, +} + +impl OutputCursor { + async fn recv(&mut self) -> Option { + loop { + let event = { + let state = lock(&self.output.state); + let oldest = state + .events + .front() + .map_or(state.next_sequence, |event| event.sequence); + if self.next_sequence < oldest { + self.next_sequence = oldest; + } + state + .events + .get(usize::try_from(self.next_sequence.saturating_sub(oldest)).ok()?) + .cloned() + }; + if let Some(event) = event { + self.next_sequence = event.sequence.saturating_add(1); + return Some(event); + } + if self.version.changed().await.is_err() { + return None; + } + } + } +} + +async fn bridge_process( + stream: tokio::io::DuplexStream, + process: Arc, +) -> Result<(), String> { + let _guard = process.acquire_attachment()?; + let (mut reader, mut writer) = tokio::io::split(stream); + let input_process = process.clone(); + let mut input = tokio::spawn(async move { + while let Some((channel, payload)) = read_stream_frame(&mut reader).await? { + match channel { + STREAM_STDIN => { + let mut stdin = input_process.stdin.lock().await; + let Some(stdin) = stdin.as_mut() else { + return Err(io::Error::new(io::ErrorKind::BrokenPipe, "stdin closed")); + }; + stdin.write_all(&payload).await?; + stdin.flush().await?; + } + STREAM_STDIN_CLOSED => { + input_process.stdin.lock().await.take(); + } + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unexpected MXC input stream channel", + )); + } + } + } + Ok::<(), io::Error>(()) + }); + let mut cursor = process.output.cursor(); + loop { + tokio::select! { + result = &mut input => { + return result + .map_err(|error| format!("join MXC process input: {error}"))? + .map_err(|error| format!("read MXC process input: {error}")); + } + event = cursor.recv() => { + let Some(event) = event else { return Ok(()) }; + write_stream_frame(&mut writer, event.channel, &event.payload) + .await + .map_err(|error| format!("write MXC process output: {error}"))?; + if event.channel == STREAM_EXIT { + return Ok(()); + } + } + } + } +} + +fn guest_error(kind: BoundaryErrorKind, message: impl Into) -> Response { + Response::Error { + kind, + message: message.into(), + } +} + +fn publish_provider_environment( + installed: &mut (u64, HashMap, u64), + generation: u64, + revision: u64, + environment: &HashMap, +) -> Response { + // The revision is an opaque fingerprint, not publication order. A newer + // generation can repair an identical revision or install a smaller value. + let applied = generation > installed.2; + if applied { + *installed = (revision, environment.clone(), generation); + } + Response::ProviderEnvironmentUpdated { + revision: installed.0, + generation: installed.2, + applied, + } +} + +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn installed_ca_replaces_case_insensitive_trust_overrides() { + let certificate = Path::new(r"C:\trusted\ca.pem"); + let bundle = Path::new(r"C:\trusted\bundle.pem"); + let mut environment = HashMap::from([ + ("SSL_CERT_FILE".into(), "old-bundle".into()), + ("node_extra_ca_certs".into(), "old-cert".into()), + ("FOO".into(), "bar".into()), + ]); + install_ca_environment(&mut environment, certificate, bundle); + assert_eq!(environment["FOO"], "bar"); + assert!(!environment.contains_key("node_extra_ca_certs")); + for key in ["NODE_EXTRA_CA_CERTS", "DENO_CERT"] { + assert_eq!(environment[key], certificate.display().to_string()); + } + for key in [ + "SSL_CERT_FILE", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "GIT_SSL_CAINFO", + ] { + assert_eq!(environment[key], bundle.display().to_string()); + } + } + + #[test] + fn provider_publications_are_ordered_independently_of_opaque_revisions() { + let mut installed = (99, HashMap::new(), 0); + for (generation, revision, value) in [(1, 99, "first"), (2, 99, "repair"), (3, 1, "new")] { + let environment = HashMap::from([("KEY".into(), value.into())]); + assert!(matches!( + publish_provider_environment(&mut installed, generation, revision, &environment), + Response::ProviderEnvironmentUpdated { applied: true, .. } + )); + assert_eq!(installed, (revision, environment, generation)); + } + let retained = installed.clone(); + for generation in [0, 1, 2, 3] { + assert!(matches!( + publish_provider_environment(&mut installed, generation, 100, &HashMap::new()), + Response::ProviderEnvironmentUpdated { + revision: 1, + generation: 3, + applied: false + } + )); + assert_eq!(installed, retained); + } + } + + #[tokio::test] + async fn forwarding_bridges_a_dynamic_target_over_the_shared_stream() { + tokio::time::timeout(Duration::from_secs(10), async { + let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .await + .expect("bind real forwarding target"); + let address = listener.local_addr().expect("target address"); + let echo = tokio::spawn(async move { + let (mut socket, _) = listener + .accept() + .await + .expect("accept forwarded connection"); + let mut request = [0_u8; 4]; + socket + .read_exact(&mut request) + .await + .expect("read target data"); + assert_eq!(&request, b"ping"); + socket.write_all(b"pong").await.expect("write target data"); + socket.shutdown().await.expect("close target output"); + }); + let (mut client, server) = tokio::io::duplex(4096); + let bridge = tokio::spawn(forward_loopback( + server, + "forward-1", + address.ip(), + address.port(), + )); + let response: ResponseEnvelope = read_frame_async(&mut client) + .await + .expect("protocol response"); + assert_eq!(response.request_id, "forward-1"); + assert!(matches!(response.response, Response::PortConnected)); + client + .write_all(b"ping") + .await + .expect("send forwarded data"); + client.shutdown().await.expect("close client output"); + let mut reply = Vec::new(); + client + .read_to_end(&mut reply) + .await + .expect("receive forwarded data"); + assert_eq!(reply, b"pong"); + bridge + .await + .expect("join bridge") + .expect("bridge succeeded"); + echo.await.expect("join real target"); + }) + .await + .expect("forwarding completes without stalling"); + } + + #[tokio::test] + async fn forwarding_rejects_non_loopback_and_zero_ports() { + for (host, port) in [("192.0.2.1", 80), ("127.0.0.1", 0), ("::1", 0)] { + let (mut client, server) = tokio::io::duplex(4096); + forward_loopback(server, "denied", host.parse().expect("IP address"), port) + .await + .expect("write denial"); + let response: ResponseEnvelope = read_frame_async(&mut client) + .await + .expect("denial response"); + assert!(matches!( + response.response, + Response::Error { + kind: BoundaryErrorKind::Denied, + .. + } + )); + } + } + + #[tokio::test] + async fn managed_launch_preserves_arguments_and_retains_real_process_output() { + let process = ManagedProcess::spawn( + "launch-test".to_string(), + ProcessKindWire::Main, + std::env::var("COMSPEC").expect("Windows command interpreter"), + vec![ + "/D".to_string(), + "/C".to_string(), + "echo relay-free launch".to_string(), + ], + None, + HashMap::new(), + ) + .await + .expect("launch real Windows process"); + let guard = process.acquire_attachment().expect("first attachment"); + assert!(process.acquire_attachment().is_err()); + drop(guard); + let _guard = process + .acquire_attachment() + .expect("attachment can reconnect"); + let status = tokio::time::timeout(Duration::from_secs(10), process.wait()) + .await + .expect("process exits"); + assert!(matches!(status, ExitStatusWire::Exited(0))); + // Drain stdout independently: exit notification can precede the reader. + let mut cursor = process.output.cursor(); + let output = tokio::time::timeout(Duration::from_secs(10), async { + let mut output = Vec::new(); + loop { + let event = cursor.recv().await.expect("retained output event"); + if event.channel == STREAM_STDOUT { + output.extend(event.payload); + if String::from_utf8_lossy(&output).contains("relay-free launch") { + return output; + } + } + } + }) + .await + .expect("stdout is retained"); + assert!(String::from_utf8_lossy(&output).contains("relay-free launch")); + } + + #[test] + fn output_retention_is_bounded_and_reports_truncation() { + let output = OutputLog::new(); + output.publish(STREAM_STDOUT, vec![1; OUTPUT_BUFFER_BYTES]); + output.publish(STREAM_STDOUT, vec![2; 1]); + let window = output.window(); + assert!(window.truncated); + assert_eq!(window.first_sequence, 1); + assert_eq!(window.next_sequence, 2); + } +} diff --git a/crates/openshell-mxc-boundary/tests/cli.rs b/crates/openshell-mxc-boundary/tests/cli.rs new file mode 100644 index 0000000000..54326142e2 --- /dev/null +++ b/crates/openshell-mxc-boundary/tests/cli.rs @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Real executable checks, without starting a mock MXC runtime. + +use std::process::Command; + +#[test] +fn help_and_version_identify_dedicated_boundary() { + for argument in ["--help", "--version"] { + let output = Command::new(env!("CARGO_BIN_EXE_openshell-windows-sandbox")) + .arg(argument) + .output() + .expect("run dedicated boundary executable"); + assert!(output.status.success()); + let text = String::from_utf8_lossy(&output.stdout); + assert!(text.contains("openshell-windows-sandbox")); + } +} + +#[test] +fn bootstrap_is_required_and_missing_bootstrap_fails_closed() { + let binary = env!("CARGO_BIN_EXE_openshell-windows-sandbox"); + let output = Command::new(binary) + .output() + .expect("run without arguments"); + assert!(!output.status.success()); + assert!( + output.stdout.is_empty(), + "startup errors must stay on stderr" + ); + assert!(String::from_utf8_lossy(&output.stderr).contains("--bootstrap")); + + // Unique absent path: never create bootstrap material or launch a workload. + let absent = std::env::temp_dir().join(format!( + "openshell-mxc-absent-bootstrap-{}.json", + std::process::id() + )); + assert!(!absent.exists()); + let output = Command::new(binary) + .arg("--bootstrap") + .arg(absent) + .env("OPENSHELL_LOG_LEVEL", "info") + .env_remove("RUST_LOG") + .output() + .expect("run with absent bootstrap"); + assert!(!output.status.success()); + assert!(output.stdout.is_empty(), "startup logs must stay on stderr"); + #[cfg(target_os = "windows")] + assert!(String::from_utf8_lossy(&output.stderr).contains("read boundary config")); + #[cfg(not(target_os = "windows"))] + assert!(String::from_utf8_lossy(&output.stderr).contains("requires Windows MXC")); +} diff --git a/crates/openshell-mxc-boundary/tests/composition.rs b/crates/openshell-mxc-boundary/tests/composition.rs new file mode 100644 index 0000000000..a58ffbe47b --- /dev/null +++ b/crates/openshell-mxc-boundary/tests/composition.rs @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Guard the relay-free library boundary without starting a mock runtime. + +#[test] +fn dedicated_boundary_does_not_embed_driver_or_supervisor() { + let manifest = include_str!("../Cargo.toml"); + assert!(manifest.contains("name = \"openshell-windows-sandbox\"")); + assert!(!manifest.contains("openshell-driver-mxc")); + assert!(!manifest.contains("openshell-supervisor")); + let runtime = include_str!("../src/runtime.rs"); + assert!(!runtime.contains("openshell-supervisor-relay")); +} + +#[test] +fn shared_runtime_keeps_platform_audit_interpretation_in_backend_extension() { + let protocol = include_str!("../../openshell-sandbox-backend/src/boundary_protocol.rs"); + assert!(!protocol.contains("MxcSandboxAuditEvidence")); + assert!(!protocol.contains("WindowsMxc")); + let runtime = include_str!("../../openshell-sandbox-backend/src/runtime.rs"); + assert!(runtime.contains("self.audit_validator.validate(&confirmation.backend_audit)?")); + assert!(runtime.contains("confirmation.properties != properties")); + let composition = include_str!("../../openshell-supervisor/src/isolation_backends.rs"); + assert!(composition.contains("MxcRuntimeBackend::new")); + let backend = include_str!("../src/backend.rs"); + assert!(backend.contains("impl IsolationBackend for MxcRuntimeBackend")); + assert!(backend.contains("MxcBoundaryAuditValidator")); + assert!(backend.contains("self.transport.attach(descriptor, sandbox).await")); +} + +#[test] +fn only_dedicated_sandbox_delegates_to_the_windows_boundary_library() { + let manifest = include_str!("../../openshell-sandbox/Cargo.toml"); + assert!(!manifest.contains("\nwindows = ")); + assert!(!manifest.contains("openshell-mxc-boundary")); + let sandbox = include_str!("../../openshell-sandbox/src/boundary_server.rs"); + assert!(!sandbox.contains("openshell_mxc_boundary::run")); + assert!(!sandbox.contains("mod windows;")); + let main = include_str!("../src/main.rs"); + assert!(main.contains("openshell_mxc_boundary::run(&args.bootstrap)")); + let driver = include_str!("../../openshell-driver-mxc/src/driver.rs"); + assert!(driver.contains("sibling_binary(\"openshell-windows-sandbox.exe\")")); +} diff --git a/crates/openshell-policy-schema/src/lib.rs b/crates/openshell-policy-schema/src/lib.rs index 0adef3ac40..5c35302cec 100644 --- a/crates/openshell-policy-schema/src/lib.rs +++ b/crates/openshell-policy-schema/src/lib.rs @@ -193,6 +193,12 @@ pub struct PolicyDocument { skip_serializing_if = "Option::is_none" )] pub process: Option, + #[serde( + default, + deserialize_with = "deserialize_non_null_optional_field", + skip_serializing_if = "Option::is_none" + )] + pub ui: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub network_policies: BTreeMap, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] @@ -234,6 +240,34 @@ pub struct ProcessPolicy { pub run_as_group: String, } +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UiPolicy { + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub allow_graphical_ui: bool, + #[serde(default, skip_serializing_if = "UiClipboardAccess::is_none")] + pub clipboard: UiClipboardAccess, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub allow_input_injection: bool, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UiClipboardAccess { + #[default] + None, + Read, + Write, + All, +} + +impl UiClipboardAccess { + #[must_use] + pub const fn is_none(&self) -> bool { + matches!(self, Self::None) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct NetworkPolicyRule { @@ -698,6 +732,7 @@ fn inspect_document(root: &serde_yml::Value) -> InspectionResult { "filesystem_policy", "landlock", "process", + "ui", "network_policies", "network_middlewares", ], @@ -717,6 +752,11 @@ fn inspect_document(root: &serde_yml::Value) -> InspectionResult { "process", &["run_as_user", "run_as_group"], )?; + inspect_named( + root.get("ui"), + "ui", + &["allow_graphical_ui", "clipboard", "allow_input_injection"], + )?; for (name, rule) in open_map(root.get("network_policies")) { let path = join("network_policies", name); @@ -1176,6 +1216,7 @@ mod tests { for source in [ "version: 1\nfilesystem_policy: null\n", "version: 1\nprocess: null\n", + "version: 1\nui: null\n", "version: 1\nmetadata: null\n", "version: 1\nnetwork_policies:\n x:\n endpoints:\n - host: x\n port: 443\n mcp: null\n", ] { @@ -1212,6 +1253,7 @@ mod tests { "landlock.future", ), ("version: 1\nprocess: { future: true }\n", "process.future"), + ("version: 1\nui: { future: true }\n", "ui.future"), ( "version: 1\nnetwork_policies: { api: { future: true } }\n", "network_policies.api.future", diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index 6d924b1f1f..fa97c2b87c 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -31,9 +31,27 @@ use openshell_core::mcp::{ use openshell_core::proto::{ FilesystemPolicy, GraphqlOperation, L7Allow, L7DenyRule, L7QueryMatcher, L7Rule, LandlockPolicy, McpOptions, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, ProcessPolicy, - SandboxPolicy, + SandboxPolicy, UiClipboardAccess, UiPolicy, }; +const fn ui_clipboard_to_proto(value: UiClipboardAccessDef) -> UiClipboardAccess { + match value { + UiClipboardAccessDef::None => UiClipboardAccess::None, + UiClipboardAccessDef::Read => UiClipboardAccess::Read, + UiClipboardAccessDef::Write => UiClipboardAccess::Write, + UiClipboardAccessDef::All => UiClipboardAccess::All, + } +} + +fn ui_clipboard_from_proto(value: i32) -> UiClipboardAccessDef { + match UiClipboardAccess::try_from(value).unwrap_or(UiClipboardAccess::Unspecified) { + UiClipboardAccess::Unspecified | UiClipboardAccess::None => UiClipboardAccessDef::None, + UiClipboardAccess::Read => UiClipboardAccessDef::Read, + UiClipboardAccess::Write => UiClipboardAccessDef::Write, + UiClipboardAccess::All => UiClipboardAccessDef::All, + } +} + pub use compose::{ PROVIDER_RULE_NAME_PREFIX, ProviderPolicyLayer, compose_effective_policy, is_provider_rule_name, provider_rule_name, strip_provider_rule_names, @@ -66,6 +84,7 @@ use openshell_policy_schema::{ NetworkCredentialBinding as NetworkCredentialBindingDef, NetworkEndpoint as NetworkEndpointDef, NetworkPolicyRule as NetworkPolicyRuleDef, ParameterMatcher as ParamMatcherDef, PolicyDocument as PolicyFile, ProcessPolicy as ProcessDef, QueryMatcher as QueryMatcherDef, + UiClipboardAccess as UiClipboardAccessDef, UiPolicy as UiDef, }; fn json_rpc_config_from_proto(max_body_bytes: u32) -> Option { @@ -608,6 +627,11 @@ fn to_proto(raw: PolicyFile) -> Result { run_as_user: p.run_as_user, run_as_group: p.run_as_group, }), + ui: raw.ui.map(|ui| UiPolicy { + allow_graphical_ui: ui.allow_graphical_ui, + clipboard: ui_clipboard_to_proto(ui.clipboard) as i32, + allow_input_injection: ui.allow_input_injection, + }), network_policies, network_middlewares, }) @@ -651,6 +675,12 @@ fn from_proto(policy: &SandboxPolicy) -> Result { } }); + let ui = policy.ui.as_ref().map(|ui| UiDef { + allow_graphical_ui: ui.allow_graphical_ui, + clipboard: ui_clipboard_from_proto(ui.clipboard), + allow_input_injection: ui.allow_input_injection, + }); + let network_policies = policy .network_policies .iter() @@ -795,6 +825,7 @@ fn from_proto(policy: &SandboxPolicy) -> Result { filesystem_policy, landlock, process, + ui, network_policies, network_middlewares, }) @@ -988,6 +1019,7 @@ pub fn restrictive_default_policy() -> SandboxPolicy { compatibility: "best_effort".into(), }), process: None, + ui: None, network_policies: HashMap::new(), network_middlewares: HashMap::default(), } @@ -1023,6 +1055,8 @@ const MAX_PATH_LENGTH: usize = 4096; pub enum PolicyViolation { /// An explicit `run_as_user` or `run_as_group` is unsafe. InvalidProcessIdentity { field: &'static str, value: String }, + /// The protobuf carries a clipboard enum value unknown to this version. + InvalidUiClipboardAccess { value: i32 }, /// A filesystem path contains `..` components. PathTraversal { path: String }, /// A filesystem path is not absolute (does not start with `/`). @@ -1124,6 +1158,12 @@ impl fmt::Display for PolicyViolation { "{field} must be 'sandbox' or a numeric UID/GID in range [{MIN_SANDBOX_UID}, {MAX_SANDBOX_UID}], got '{value}'" ) } + Self::InvalidUiClipboardAccess { value } => { + write!( + f, + "ui clipboard access has unknown enum value {value}; expected unspecified, none, read, write, or all" + ) + } Self::PathTraversal { path } => { write!(f, "path contains '..' traversal component: {path}") } @@ -1330,6 +1370,7 @@ impl fmt::Display for PolicyViolation { /// /// Checks performed: /// - Explicit `run_as_user` / `run_as_group` fields must be safe identities +/// - UI clipboard access must use a recognized enum value /// - Filesystem paths must be absolute (start with `/`) /// - Filesystem paths must not contain `..` components /// - Read-write paths must not be overly broad (just `/`) @@ -1444,6 +1485,14 @@ fn validate_sandbox_policy_with_mcp_presence( }); } + if let Some(ref ui) = policy.ui + && UiClipboardAccess::try_from(ui.clipboard).is_err() + { + violations.push(PolicyViolation::InvalidUiClipboardAccess { + value: ui.clipboard, + }); + } + if let Some(ref fs) = policy.filesystem && let Err(errors) = validate_filesystem_paths(&fs.read_only, &fs.read_write) { @@ -1855,6 +1904,7 @@ fn validate_and_canonicalize_mcp_policy_schema( .map_err(|violations| PolicyValidationError { violations })?; materialize_default_mcp_versions(&mut policy); canonicalize_mcp_version_allowlists(&mut policy); + canonicalize_ui_defaults(&mut policy); validate_mcp_policy_schema(&policy, McpVersionPresence::RequireMaterialized) .map_err(|violations| PolicyValidationError { violations })?; Ok(policy) @@ -1877,6 +1927,7 @@ pub fn validate_and_canonicalize_sandbox_policy( .map_err(|violations| PolicyValidationError { violations })?; materialize_default_mcp_versions(&mut policy); canonicalize_mcp_version_allowlists(&mut policy); + canonicalize_ui_defaults(&mut policy); debug_assert!( validate_sandbox_policy(&policy).is_ok(), "validated MCP canonicalization must preserve every policy invariant" @@ -1884,6 +1935,20 @@ pub fn validate_and_canonicalize_sandbox_policy( Ok(policy) } +/// Materialize protobuf UI defaults that have a distinct canonical enum value. +/// +/// Proto3 clients commonly leave `clipboard` at `Unspecified(0)`. `OpenShell` +/// defines that value as deny, so canonical policy state stores the equivalent +/// explicit `None(1)`. This keeps protobuf, YAML, hashes, and static-field +/// comparisons stable across a serialize/parse round trip. +fn canonicalize_ui_defaults(policy: &mut SandboxPolicy) { + if let Some(ui) = policy.ui.as_mut() + && ui.clipboard == UiClipboardAccess::Unspecified as i32 + { + ui.clipboard = UiClipboardAccess::None as i32; + } +} + /// Replace absent protobuf MCP options and empty revision lists with the /// single pinned policy default while preserving every explicit MCP option. pub(crate) fn materialize_default_mcp_versions(policy: &mut SandboxPolicy) { @@ -2003,6 +2068,99 @@ network_policies: assert!(json.get("network_policies").is_some()); } + #[test] + fn ui_absence_and_explicit_empty_remain_distinct() { + let absent = parse_sandbox_policy("version: 1\n").expect("absent UI parses"); + assert!(absent.ui.is_none()); + let absent_yaml = serialize_sandbox_policy(&absent).expect("absent UI serializes"); + assert!(!absent_yaml.contains("\nui:")); + + let explicit = parse_sandbox_policy("version: 1\nui: {}\n").expect("empty UI parses"); + let ui = explicit.ui.as_ref().expect("UI presence preserved"); + assert!(!ui.allow_graphical_ui); + assert_eq!(ui.clipboard, UiClipboardAccess::None as i32); + assert!(!ui.allow_input_injection); + + let explicit_yaml = serialize_sandbox_policy(&explicit).expect("empty UI serializes"); + assert!(explicit_yaml.contains("ui: {}"), "got:\n{explicit_yaml}"); + let reparsed = parse_sandbox_policy(&explicit_yaml).expect("empty UI reparses"); + assert!(reparsed.ui.is_some()); + } + + #[test] + fn ui_policy_round_trips_all_clipboard_directions() { + for (wire, expected) in [ + ("none", UiClipboardAccess::None), + ("read", UiClipboardAccess::Read), + ("write", UiClipboardAccess::Write), + ("all", UiClipboardAccess::All), + ] { + let yaml = format!( + "version: 1\nui:\n allow_graphical_ui: true\n clipboard: {wire}\n allow_input_injection: true\n" + ); + let policy = parse_sandbox_policy(&yaml).expect("UI policy parses"); + let ui = policy.ui.as_ref().expect("UI policy present"); + assert!(ui.allow_graphical_ui); + assert_eq!(ui.clipboard, expected as i32); + assert!(ui.allow_input_injection); + + let serialized = serialize_sandbox_policy(&policy).expect("UI policy serializes"); + let reparsed = parse_sandbox_policy(&serialized).expect("UI policy reparses"); + assert_eq!(reparsed, policy); + } + } + + #[test] + fn ui_unspecified_clipboard_canonicalizes_to_none_across_yaml_round_trip() { + let raw = SandboxPolicy { + version: 1, + ui: Some(UiPolicy { + allow_graphical_ui: true, + clipboard: UiClipboardAccess::Unspecified as i32, + ..Default::default() + }), + ..Default::default() + }; + + let canonical = validate_and_canonicalize_sandbox_policy(raw) + .expect("unspecified clipboard must be a valid deny default"); + assert_eq!( + canonical.ui.as_ref().expect("UI remains present").clipboard, + UiClipboardAccess::None as i32 + ); + + let yaml = serialize_sandbox_policy(&canonical).expect("canonical UI serializes"); + let reparsed = parse_sandbox_policy(&yaml).expect("canonical UI reparses"); + assert_eq!(reparsed, canonical); + } + + #[test] + fn ui_policy_rejects_unknown_yaml_clipboard_value() { + let error = parse_sandbox_policy("version: 1\nui:\n clipboard: execute\n") + .expect_err("unknown clipboard value must fail"); + assert!( + error + .to_string() + .contains("failed to decode sandbox policy fields") + ); + } + + #[test] + fn ui_policy_validation_rejects_unknown_proto_clipboard_value() { + let policy = SandboxPolicy { + ui: Some(UiPolicy { + clipboard: 99, + ..Default::default() + }), + ..Default::default() + }; + let violations = validate_sandbox_policy(&policy).expect_err("unknown enum must fail"); + assert_eq!( + violations, + vec![PolicyViolation::InvalidUiClipboardAccess { value: 99 }] + ); + } + /// Verify that `allowed_ips` survives the round-trip. #[test] fn round_trip_preserves_allowed_ips() { @@ -3702,6 +3860,7 @@ network_policies: process: None, filesystem: None, landlock: None, + ui: None, network_policies: HashMap::new(), network_middlewares: HashMap::default(), }; @@ -4160,6 +4319,7 @@ network_policies: }), filesystem: None, landlock: None, + ui: None, network_policies: HashMap::new(), network_middlewares: HashMap::default(), }; @@ -4176,6 +4336,7 @@ network_policies: }), filesystem: None, landlock: None, + ui: None, network_policies: HashMap::new(), network_middlewares: HashMap::default(), }; @@ -4248,6 +4409,7 @@ network_policies: }), filesystem: None, landlock: None, + ui: None, network_policies: HashMap::new(), network_middlewares: HashMap::default(), }; diff --git a/crates/openshell-prover/src/containment.rs b/crates/openshell-prover/src/containment.rs index 9a9c9cb72a..fa9b832868 100644 --- a/crates/openshell-prover/src/containment.rs +++ b/crates/openshell-prover/src/containment.rs @@ -83,6 +83,11 @@ impl ContainmentEndpoint for Endpoint { pub fn parse_policy_str(source: &str) -> Result { let document = openshell_policy_schema::parse_policy(source) .map_err(|error| ParsePolicyError(format!("invalid policy: {error:#}")))?; + if document.ui.is_some() { + return Err(ParsePolicyError( + "UI policy containment is not supported".to_string(), + )); + } let mut filesystem_policy = document.effective_filesystem_policy(); normalize_filesystem_paths(&mut filesystem_policy)?; let PolicyDocument { @@ -92,6 +97,7 @@ pub fn parse_policy_str(source: &str) -> Result CheckOptions { CheckOptions::new(Duration::from_secs(10)) } diff --git a/crates/openshell-sandbox-backend/src/boundary_protocol.rs b/crates/openshell-sandbox-backend/src/boundary_protocol.rs index 6c6c66e76c..e1867416e3 100644 --- a/crates/openshell-sandbox-backend/src/boundary_protocol.rs +++ b/crates/openshell-sandbox-backend/src/boundary_protocol.rs @@ -56,7 +56,6 @@ pub const BOUNDARY_CONNECTION_WINDOW_BYTES: u32 = BOUNDARY_MAX_CONCURRENT_STREAM + BOUNDARY_CONNECTION_WINDOW_RESERVE_BYTES; // HTTP/2 caps any flow-control window at 2^31 - 1. const _: () = assert!(BOUNDARY_CONNECTION_WINDOW_BYTES <= i32::MAX as u32); - /// Capability masks measured from `/proc//status` by the `OpenShell` /// co-located runtime. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -437,6 +436,12 @@ pub struct SandboxRuntimeDescriptor { /// network supervisor cannot use the boundary's resolver view. #[serde(default)] pub host_gateway_ip: Option, + /// Optional generation-scoped explicit proxy owned by the host + /// supervisor. Backends set this only when their outer fence routes the + /// workload to this listener and the boundary cannot provide staged + /// socket mediation. + #[serde(default)] + pub direct_proxy: Option, /// Driver-specific immutable resource coordinates bound at attach (for /// example pod UID, VM generation, or container ID). #[serde(default)] @@ -455,6 +460,7 @@ impl fmt::Debug for SandboxRuntimeDescriptor { .field("transport", &self.transport) .field("tls", &self.tls) .field("host_gateway_ip", &self.host_gateway_ip) + .field("direct_proxy", &self.direct_proxy) .field("resource_claims", &self.resource_claims) .field("outer_fence", &self.outer_fence) .finish() @@ -509,8 +515,13 @@ pub struct BoundaryConfig { pub resource_claim_files: std::collections::BTreeMap, /// Exact identity already applied by the runtime to the sandbox process. pub workload_identity: openshell_isolation_interface::contract::ResolvedWorkloadIdentity, - /// Backend-neutral projection of the validated outer network fence. + /// Backend-neutral projection of the validated outer fence. pub outer_fence: OuterFenceGuarantees, + /// Authenticated proxy URL injected into workload children. It is staged + /// only in this protected one-use configuration and is never inherited by + /// the trusted sandbox process itself. + #[serde(default)] + pub direct_proxy_url: Option, /// Driver-resolved environment exposed only to workload processes. #[serde(default)] pub child_env: std::collections::HashMap, @@ -539,6 +550,10 @@ impl fmt::Debug for BoundaryConfig { .field("resource_claim_files", &self.resource_claim_files) .field("workload_identity", &self.workload_identity) .field("outer_fence", &self.outer_fence) + .field( + "direct_proxy_url", + &self.direct_proxy_url.as_ref().map(|_| ""), + ) .field("child_env_keys", &self.child_env.keys().collect::>()) .finish() } @@ -1503,7 +1518,6 @@ mod tests { audit.seccomp.task_memory_writes_disabled = false; assert!(audit.validate().is_err()); } - #[test] fn binary_identity_wire_rejects_ambiguous_or_invalid_shapes() { for encoded in [ diff --git a/crates/openshell-sandbox-backend/src/runtime.rs b/crates/openshell-sandbox-backend/src/runtime.rs index e015a95814..07638ddd63 100644 --- a/crates/openshell-sandbox-backend/src/runtime.rs +++ b/crates/openshell-sandbox-backend/src/runtime.rs @@ -135,6 +135,7 @@ impl IsolationBackend for OpenShellRuntimeBackend { })?; validate_runtime_descriptor(&runtime_descriptor, &sandbox)?; let host_gateway_ip = runtime_descriptor.host_gateway_ip; + let direct_proxy = runtime_descriptor.direct_proxy.clone(); let resource_claims = runtime_descriptor.resource_claims.clone(); let generation = runtime_descriptor.generation.clone(); let session_id = runtime_descriptor.session_id; @@ -166,6 +167,7 @@ impl IsolationBackend for OpenShellRuntimeBackend { sandbox_id: sandbox.sandbox_id, mediation: Arc::new(RemoteNetworkMediation { client }), host_gateway_ip, + direct_proxy, ca_file_paths: self.ca_file_paths.clone(), provider_credentials: self.provider_credentials.clone(), identity: sandbox.identity, @@ -235,6 +237,18 @@ fn validate_runtime_descriptor( } } validate_client_tls(&runtime_descriptor.tls)?; + if let Some(proxy) = &runtime_descriptor.direct_proxy + && (!proxy.bind_addr.ip().is_loopback() + || proxy.bind_addr.port() == 0 + || proxy.authorization.trim().is_empty() + || proxy.authorization.contains(['\r', '\n']) + || !proxy.binary_identity.executable.path.is_absolute()) + { + return Err(BackendError::Descriptor( + "direct proxy requires a loopback listener, a single-line authorization value, and an absolute binary identity" + .to_string(), + )); + } Ok(()) } @@ -312,6 +326,7 @@ struct RemoteBound { sandbox_id: String, mediation: Arc, host_gateway_ip: Option, + direct_proxy: Option, ca_file_paths: Arc>>, provider_credentials: openshell_core::provider_credentials::ProviderCredentialState, identity: openshell_isolation_interface::contract::ResolvedWorkloadIdentity, @@ -331,6 +346,12 @@ impl BoundBoundary for RemoteBound { self.host_gateway_ip } + fn direct_proxy_configuration( + &self, + ) -> Option { + self.direct_proxy.clone() + } + async fn confirm(self: Box) -> Result { let response = self.client.call_idempotent(Request::Confirm).await?; let Response::Confirmed { confirmation } = response else { @@ -2790,6 +2811,7 @@ mod tests { }, tls, host_gateway_ip: None, + direct_proxy: None, resource_claims: std::collections::BTreeMap::new(), outer_fence: test_outer_fence(), } @@ -2966,6 +2988,7 @@ mod tests { )); let bound = RemoteBound { audit_validator: Arc::new(crate::audit::LinuxBoundaryAuditValidator), + direct_proxy: None, client: client.clone(), agent: context.agent, policy: context.policy, @@ -3055,6 +3078,7 @@ mod tests { }, tls: certificate.client_tls.clone(), host_gateway_ip: Some(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)), + direct_proxy: None, resource_claims: std::collections::BTreeMap::new(), outer_fence: test_outer_fence(), }; @@ -3075,6 +3099,7 @@ mod tests { }, tls: test_certificate().client_tls, host_gateway_ip: None, + direct_proxy: None, resource_claims: std::collections::BTreeMap::new(), outer_fence: test_outer_fence(), }; @@ -3097,6 +3122,7 @@ mod tests { }, tls: test_certificate().client_tls, host_gateway_ip: None, + direct_proxy: None, resource_claims: std::collections::BTreeMap::new(), outer_fence: test_outer_fence(), }; @@ -3119,6 +3145,7 @@ mod tests { }, tls: test_certificate().client_tls, host_gateway_ip: None, + direct_proxy: None, resource_claims: std::collections::BTreeMap::new(), outer_fence: test_outer_fence(), }; @@ -3350,6 +3377,7 @@ mod tests { }, tls: certificate.client_tls, host_gateway_ip: None, + direct_proxy: None, resource_claims: std::collections::BTreeMap::new(), outer_fence: test_outer_fence(), }, @@ -3543,6 +3571,7 @@ mod tests { }, tls: certificate.client_tls, host_gateway_ip: None, + direct_proxy: None, resource_claims: std::collections::BTreeMap::new(), outer_fence: test_outer_fence(), }, diff --git a/crates/openshell-sandbox/Cargo.toml b/crates/openshell-sandbox/Cargo.toml index 50d91f0abf..fb0f867635 100644 --- a/crates/openshell-sandbox/Cargo.toml +++ b/crates/openshell-sandbox/Cargo.toml @@ -61,6 +61,7 @@ tokio-rustls = { workspace = true } base64 = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +url = { workspace = true } # Logging tracing = { workspace = true } diff --git a/crates/openshell-sandbox/src/boundary_server.rs b/crates/openshell-sandbox/src/boundary_server.rs index f84f98d424..e8f0884c8e 100644 --- a/crates/openshell-sandbox/src/boundary_server.rs +++ b/crates/openshell-sandbox/src/boundary_server.rs @@ -5524,5 +5524,8 @@ pub fn run_boundary( _config_path: &Path, _qualification: crate::RuntimeQualification, ) -> Result<(), String> { - Err("boundary mode is supported only on Linux".to_string()) + Err( + "openshell-sandbox boundary requires Linux; use openshell-windows-sandbox on Windows" + .to_string(), + ) } diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index a8d31fbfe9..dea7b75e21 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -5,15 +5,19 @@ #[cfg(target_os = "linux")] mod accept_interrupt; +#[cfg(target_os = "linux")] pub mod boundary_exec; +#[cfg(target_os = "linux")] pub mod boundary_io; mod boundary_server; +#[cfg(target_os = "linux")] pub mod child_env; pub mod container_log; #[cfg(target_os = "linux")] pub(crate) mod delegated; #[cfg(target_os = "linux")] pub mod main_session; +#[cfg(target_os = "linux")] pub mod managed_children; #[cfg(target_os = "linux")] mod network_broker; @@ -23,7 +27,9 @@ pub mod perf; pub mod process; #[cfg(target_os = "linux")] mod provider_files; +#[cfg(target_os = "linux")] mod pty; +#[cfg(target_os = "linux")] pub mod sandbox; #[cfg(target_os = "linux")] pub mod sftp; @@ -46,11 +52,10 @@ pub struct RuntimeQualification { pub tcp_deny_round_trip: bool, } -/// Placeholder used when compiling the package on a non-Linux host. +/// Linux runtime qualification is not used on other platforms. /// -/// The sandbox binary rejects execution on those hosts before constructing a -/// qualification, but retaining the type keeps the library API portable for -/// workspace-wide checks. +/// This executable's boundary mode requires Linux. Windows MXC uses the separate +/// `openshell-windows-sandbox` executable and its platform-specific confirmation. #[cfg(not(target_os = "linux"))] #[derive(Clone, Copy, Debug)] pub struct RuntimeQualification; diff --git a/crates/openshell-sandbox/src/main.rs b/crates/openshell-sandbox/src/main.rs index 99f37b202e..1c70efd861 100644 --- a/crates/openshell-sandbox/src/main.rs +++ b/crates/openshell-sandbox/src/main.rs @@ -24,7 +24,7 @@ use tracing_subscriber::{Layer, layer::SubscriberExt, util::SubscriberInitExt}; const COPY_SELF_SUBCOMMAND: &str = "copy-self"; const BOOTSTRAP_SUBCOMMAND: &str = "bootstrap"; const SEED_WORKSPACE_SUBCOMMAND: &str = "seed-workspace"; -#[cfg(any(target_os = "linux", test))] +#[cfg(target_os = "linux")] const KUBERNETES_BOOTSTRAP_SECRET_FILES: [&str; 3] = ["boundary.json", "tls.crt", "tls.key"]; #[cfg(target_os = "linux")] const BOOTSTRAP_INPUT_ROOT: &str = "/.openshell/bootstrap-input"; @@ -1622,7 +1622,7 @@ fn run_kubernetes_bootstrap() -> Result<()> { )) } -#[cfg(any(target_os = "linux", test))] +#[cfg(target_os = "linux")] fn stage_kubernetes_bootstrap_at(source: &Path, runtime: &Path, state: &Path) -> Result<()> { use std::fs::{self, OpenOptions}; use std::os::unix::fs::PermissionsExt as _; @@ -1679,7 +1679,7 @@ fn stage_kubernetes_bootstrap_at(source: &Path, runtime: &Path, state: &Path) -> Ok(()) } -#[cfg(any(target_os = "linux", test))] +#[cfg(target_os = "linux")] fn copy_projected_secret_file( source_root: &Path, name: &str, @@ -1697,7 +1697,7 @@ fn copy_projected_secret_file( copy_regular_file(&canonical_source, destination, mode) } -#[cfg(any(target_os = "linux", test))] +#[cfg(target_os = "linux")] fn copy_regular_file(source: &Path, destination: &Path, mode: u32) -> Result<()> { use std::fs::{self, OpenOptions}; use std::io::{Read as _, Write as _}; @@ -1734,10 +1734,12 @@ fn copy_regular_file(source: &Path, destination: &Path, mode: u32) -> Result<()> /// Seed the persistent workspace from the agent image as the final workload /// identity. This replaces the former root shell/tar init container. +#[cfg(target_os = "linux")] fn seed_kubernetes_workspace() -> Result<()> { seed_kubernetes_workspace_at(Path::new("/sandbox"), Path::new("/mnt/openshell-workspace")) } +#[cfg(target_os = "linux")] fn copy_workspace_tree(source: &Path, destination: &Path) -> Result<()> { use std::fs::{self, OpenOptions}; use std::io::{Read as _, Write as _}; @@ -1790,6 +1792,7 @@ fn copy_workspace_tree(source: &Path, destination: &Path) -> Result<()> { Ok(()) } +#[cfg(target_os = "linux")] fn seed_kubernetes_workspace_at(source: &Path, destination: &Path) -> Result<()> { use std::fs::{self, OpenOptions}; use std::io::Write as _; @@ -1834,6 +1837,13 @@ fn seed_kubernetes_workspace_at(source: &Path, destination: &Path) -> Result<()> Ok(()) } +#[cfg(not(target_os = "linux"))] +fn seed_kubernetes_workspace() -> Result<()> { + Err(miette::miette!( + "Kubernetes workspace seeding is supported only on Linux" + )) +} + #[cfg(target_os = "linux")] fn run_boundary(bootstrap: &Path, log_level: &str) -> Result<()> { let console_filter = @@ -1853,7 +1863,9 @@ fn run_boundary(bootstrap: &Path, log_level: &str) -> Result<()> { #[cfg(not(target_os = "linux"))] fn run_boundary(_bootstrap: &Path, _log_level: &str) -> Result<()> { - Err(miette::miette!("openshell-sandbox requires Linux")) + Err(miette::miette!( + "openshell-sandbox requires Linux; use openshell-windows-sandbox on Windows" + )) } fn main() -> Result<()> { @@ -1900,7 +1912,7 @@ fn main() -> Result<()> { run_boundary(&args.bootstrap, &args.log_level) } -#[cfg(test)] +#[cfg(all(test, target_os = "linux"))] mod tests { use super::*; use std::os::unix::fs::PermissionsExt; diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 264cd77bbb..20b9c1abc4 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -40,8 +40,9 @@ use openshell_core::proto::compute::v1::{ compute_driver_server::ComputeDriver, watch_sandboxes_event, }; use openshell_core::proto::{ - PlatformEvent, Sandbox, SandboxCondition, SandboxPhase, SandboxRestartPolicy, SandboxSpec, - SandboxStatus, SandboxTemplate, SandboxWorkloadTemplate, ServiceEndpoint, SshSession, + PlatformEvent, Sandbox, SandboxCondition, SandboxPhase, SandboxPolicy as ProtoSandboxPolicy, + SandboxRestartPolicy, SandboxSpec, SandboxStatus, SandboxTemplate, SandboxWorkloadTemplate, + ServiceEndpoint, SshSession, }; use openshell_core::telemetry::TelemetryComputeDriver; use openshell_core::{ObjectLabels, ObjectWorkspace}; @@ -73,6 +74,24 @@ pub type DriverWatchStream = pub type SharedComputeDriver = Arc + Send + Sync>; +/// Driver-specific values that must be delivered atomically with sandbox +/// creation without expanding the public compute-driver protobuf contract. +#[derive(Clone, Default)] +pub struct SandboxCreateRuntimeInputs { + pub effective_policy: Option, + pub launch_authentication: Option>, +} + +impl SandboxCreateRuntimeInputs { + #[must_use] + pub(crate) fn new(effective_policy: ProtoSandboxPolicy) -> Self { + Self { + effective_policy: Some(effective_policy), + launch_authentication: None, + } + } +} + use traced_driver::TracedDriver; const LIFECYCLE_SWEEP_PAGE_SIZE: u32 = 1000; @@ -243,6 +262,16 @@ struct SandboxDeleteTarget { sandbox_name: String, } +/// Optional caller-owned preconditions for an identity-safe sandbox delete. +/// +/// These values are validated again while holding the sandbox lifecycle and +/// gateway-global locks, immediately before the durable `Deleting` mutation. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct SandboxDeletePreconditions { + pub expected_sandbox_id: Option, + pub expected_resource_version: Option, +} + /// Identity and driver result for a completed delete request. #[derive(Debug, Eq, PartialEq)] pub struct DeleteSandboxResult { @@ -280,6 +309,7 @@ enum BeginDelete { } #[derive(Debug, Clone)] +#[allow(clippy::struct_excessive_bools)] pub struct ComputeDriverInfoSnapshot { /// Gateway-selected driver name used for routing and `driver_config` keys. pub name: String, @@ -301,6 +331,9 @@ pub struct ComputeDriverInfoSnapshot { pub rootfs_tar_staging_dir: String, /// Maximum rootfs tar file size in bytes. pub rootfs_tar_max_bytes: u64, + /// Whether this configured driver instance completely enforces the portable + /// UI policy contract. + pub supports_ui_policy: bool, } // Startup recovery owns persisted desired state before normal observations @@ -729,6 +762,7 @@ impl ComputeRuntime { resource_capabilities: capabilities.resource_capabilities, rootfs_tar_staging_dir: capabilities.rootfs_tar_staging_dir, rootfs_tar_max_bytes: capabilities.rootfs_tar_max_bytes, + supports_ui_policy: capabilities.supports_ui_policy, }; let default_image = capabilities.default_image; let rootfs_tar_staging = Arc::new(rootfs_tar::RootfsTarStagingRegistry::new( @@ -956,14 +990,32 @@ impl ComputeRuntime { } pub async fn validate_sandbox_create(&self, sandbox: &Sandbox) -> Result<(), Status> { + self.validate_sandbox_create_with_runtime_inputs( + sandbox, + &SandboxCreateRuntimeInputs::default(), + ) + .await + } + + pub(crate) async fn validate_sandbox_create_with_runtime_inputs( + &self, + sandbox: &Sandbox, + runtime_inputs: &SandboxCreateRuntimeInputs, + ) -> Result<(), Status> { self.validate_caller_driver_config( sandbox .spec .as_ref() .and_then(|spec| spec.template.as_ref()), )?; + self.validate_policy_capabilities(sandbox, runtime_inputs.effective_policy.as_ref())?; let mut driver_sandbox = driver_sandbox_from_public(sandbox, &self.driver_info.name) .map_err(|status| *status)?; + if let Some(effective_policy) = runtime_inputs.effective_policy.as_ref() + && let Some(spec) = driver_sandbox.spec.as_mut() + { + spec.policy = Some(effective_policy.clone()); + } // Peek, never consume: create runs the same path immediately after and // must still find the token. if let Some(token) = take_staging_token(&mut driver_sandbox) { @@ -986,6 +1038,24 @@ impl ComputeRuntime { .map(|_| ()) } + fn validate_policy_capabilities( + &self, + sandbox: &Sandbox, + effective_policy: Option<&ProtoSandboxPolicy>, + ) -> Result<(), Status> { + let has_explicit_ui = effective_policy + .or_else(|| sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref())) + .and_then(|policy| policy.ui.as_ref()) + .is_some(); + if has_explicit_ui && !self.driver_info.supports_ui_policy { + return Err(Status::invalid_argument(format!( + "compute driver '{}' does not support the complete UI policy contract; remove the explicit ui section or select a supporting driver/backend", + self.driver_info.name + ))); + } + Ok(()) + } + pub async fn create_sandbox( &self, sandbox: Sandbox, @@ -1034,6 +1104,53 @@ impl ComputeRuntime { await_main_process_attachment: bool, lifecycle_guard: SandboxLifecycleGuard, global_guard: SandboxSyncGuard, + ) -> Result { + Box::pin(self.create_sandbox_with_runtime_inputs_and_guards( + sandbox, + sandbox_token, + await_main_process_attachment, + SandboxCreateRuntimeInputs { + launch_authentication, + ..Default::default() + }, + lifecycle_guard, + global_guard, + )) + .await + } + + pub(crate) async fn create_sandbox_with_runtime_inputs( + &self, + sandbox: Sandbox, + sandbox_token: Option, + await_main_process_attachment: bool, + runtime_inputs: SandboxCreateRuntimeInputs, + ) -> Result { + let (lifecycle_guard, global_guard) = self + .sandbox_create_guards(sandbox.object_id()) + .await + .map_err(|error| { + crate::grpc::persistence_error_to_status(error, "acquire sandbox mutation lock") + })?; + Box::pin(self.create_sandbox_with_runtime_inputs_and_guards( + sandbox, + sandbox_token, + await_main_process_attachment, + runtime_inputs, + lifecycle_guard, + global_guard, + )) + .await + } + + pub(crate) async fn create_sandbox_with_runtime_inputs_and_guards( + &self, + sandbox: Sandbox, + sandbox_token: Option, + await_main_process_attachment: bool, + runtime_inputs: SandboxCreateRuntimeInputs, + lifecycle_guard: SandboxLifecycleGuard, + global_guard: SandboxSyncGuard, ) -> Result { self.validate_caller_driver_config( sandbox @@ -1041,6 +1158,7 @@ impl ComputeRuntime { .as_ref() .and_then(|spec| spec.template.as_ref()), )?; + self.validate_policy_capabilities(&sandbox, runtime_inputs.effective_policy.as_ref())?; let sandbox_id = sandbox.object_id().to_string(); let mut sandbox = sandbox; @@ -1055,6 +1173,11 @@ impl ComputeRuntime { let mut driver_sandbox = driver_sandbox_from_public(&sandbox, &self.driver_info.name) .map_err(|status| *status)?; + if let Some(effective_policy) = runtime_inputs.effective_policy + && let Some(spec) = driver_sandbox.spec.as_mut() + { + spec.policy = Some(effective_policy); + } if let Some(staged) = staged.as_ref() { set_rootfs_tar_path(&mut driver_sandbox, staged.path()); } @@ -1107,7 +1230,7 @@ impl ComputeRuntime { } if let Some(spec) = driver_sandbox.spec.as_mut() { spec.await_main_process_attachment = await_main_process_attachment; - spec.launch_authentication = launch_authentication.unwrap_or_default(); + spec.launch_authentication = runtime_inputs.launch_authentication.unwrap_or_default(); } match self .driver @@ -2141,8 +2264,12 @@ impl ComputeRuntime { workspace: &str, name: &str, ) -> Result { - self.delete_sandbox_allow_missing(workspace, name, false) - .await + self.delete_sandbox_with_preconditions( + workspace, + name, + SandboxDeletePreconditions::default(), + ) + .await } pub(crate) async fn delete_sandbox_allow_missing( @@ -2151,6 +2278,55 @@ impl ComputeRuntime { name: &str, allow_missing: bool, ) -> Result { + self.delete_sandbox_with_options( + workspace, + name, + allow_missing, + SandboxDeletePreconditions::default(), + ) + .await + } + + pub(crate) async fn delete_sandbox_with_preconditions( + &self, + workspace: &str, + name: &str, + preconditions: SandboxDeletePreconditions, + ) -> Result { + self.delete_sandbox_with_options(workspace, name, false, preconditions) + .await + } + + pub(crate) async fn delete_sandbox_allow_missing_with_preconditions( + &self, + workspace: &str, + name: &str, + allow_missing: bool, + preconditions: SandboxDeletePreconditions, + ) -> Result { + if preconditions == SandboxDeletePreconditions::default() { + return self + .delete_sandbox_allow_missing(workspace, name, allow_missing) + .await; + } + self.delete_sandbox_with_options(workspace, name, allow_missing, preconditions) + .await + } + + async fn delete_sandbox_with_options( + &self, + workspace: &str, + name: &str, + allow_missing: bool, + preconditions: SandboxDeletePreconditions, + ) -> Result { + if preconditions.expected_resource_version.is_some() + && preconditions.expected_sandbox_id.is_none() + { + return Err(Status::invalid_argument( + "expected_resource_version requires expected_sandbox_id", + )); + } // Resolve and acquire both request-side locks before spawning the // owned worker. Cancellation while any of these awaits is pending is // harmless because no mutation or detached work has started. @@ -2168,11 +2344,20 @@ impl ComputeRuntime { } return Err(Status::not_found("sandbox not found")); }; + if preconditions + .expected_sandbox_id + .as_deref() + .is_some_and(|expected| expected != candidate.object_id()) + { + return Err(Status::aborted( + "sandbox identity does not match expected_sandbox_id", + )); + } let target = SandboxDeleteTarget { sandbox_id: candidate.object_id().to_string(), sandbox_name: candidate.object_name().to_string(), }; - self.delete_sandbox_target(target).await + self.delete_sandbox_target(target, preconditions).await } pub(crate) async fn delete_sandbox_by_id( @@ -2180,16 +2365,20 @@ impl ComputeRuntime { sandbox_id: &str, sandbox_name: &str, ) -> Result { - self.delete_sandbox_target(SandboxDeleteTarget { - sandbox_id: sandbox_id.to_string(), - sandbox_name: sandbox_name.to_string(), - }) + self.delete_sandbox_target( + SandboxDeleteTarget { + sandbox_id: sandbox_id.to_string(), + sandbox_name: sandbox_name.to_string(), + }, + SandboxDeletePreconditions::default(), + ) .await } async fn delete_sandbox_target( &self, target: SandboxDeleteTarget, + preconditions: SandboxDeletePreconditions, ) -> Result { let delete_guard = self.lifecycle_gates.lock_for(&target.sandbox_id).await; let global_guard = self.lock_global_for_lifecycle(&delete_guard).await; @@ -2203,7 +2392,13 @@ impl ComputeRuntime { let request_span = tracing::Span::current(); tokio::spawn( async move { - Box::pin(runtime.delete_sandbox_inner(target, delete_guard, global_guard)).await + Box::pin(runtime.delete_sandbox_inner( + target, + preconditions, + delete_guard, + global_guard, + )) + .await } .instrument(request_span), ) @@ -2218,6 +2413,7 @@ impl ComputeRuntime { async fn delete_sandbox_inner( &self, target: SandboxDeleteTarget, + preconditions: SandboxDeletePreconditions, delete_guard: SandboxLifecycleGuard, guard: tokio::sync::OwnedMutexGuard<()>, ) -> Result { @@ -2241,6 +2437,23 @@ impl ComputeRuntime { "sandbox name changed while the delete request was waiting; retry explicitly", )); } + if preconditions + .expected_sandbox_id + .as_deref() + .is_some_and(|expected| expected != current.object_id()) + { + return Err(Status::aborted( + "sandbox identity changed before delete mutation", + )); + } + if preconditions + .expected_resource_version + .is_some_and(|expected| expected != sandbox_resource_version(¤t)) + { + return Err(Status::aborted( + "sandbox resource version changed before delete mutation", + )); + } // `Started` carries both sides of the CAS transition: the durable // `Deleting` row used to fence recovery, and the prior row used only @@ -6743,6 +6956,7 @@ impl ComputeDriver for NoopTestDriver { "test", [], )), + supports_ui_policy: false, }, )) } @@ -6920,6 +7134,7 @@ pub fn new_test_runtime_with_driver( resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_ui_policy: false, }, telemetry_compute_driver: TelemetryComputeDriver::custom(), driver_process: None, @@ -6947,6 +7162,7 @@ mod tests { GetSandboxResponse, StartSandboxResponse, StopSandboxRequest, StopSandboxResponse, ValidateSandboxCreateResponse, WatchSandboxesDeletedEvent, WatchSandboxesSandboxEvent, }; + use openshell_core::proto::{SandboxPolicy as PublicSandboxPolicy, UiPolicy}; use std::collections::HashMap; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex as TestMutex}; @@ -7303,6 +7519,9 @@ mod tests { current_sandboxes: Vec, workspace_rpcs_unimplemented: bool, omit_protocol_metadata: bool, + validate_create_calls: AtomicUsize, + create_calls: AtomicUsize, + created_sandboxes: TestMutex>, } #[tonic::async_trait] @@ -7346,6 +7565,7 @@ mod tests { [], ) }), + supports_ui_policy: false, })) } @@ -7353,6 +7573,7 @@ mod tests { &self, _request: Request, ) -> Result, Status> { + self.validate_create_calls.fetch_add(1, Ordering::Relaxed); Ok(tonic::Response::new(ValidateSandboxCreateResponse {})) } @@ -7402,8 +7623,15 @@ mod tests { async fn create_sandbox( &self, - _request: Request, + request: Request, ) -> Result, Status> { + self.create_calls.fetch_add(1, Ordering::Relaxed); + if let Some(sandbox) = request.into_inner().sandbox { + self.created_sandboxes + .lock() + .expect("created sandbox observation lock poisoned") + .push(sandbox); + } Ok(tonic::Response::new(CreateSandboxResponse::default())) } @@ -7731,6 +7959,7 @@ mod tests { "test", [], )), + supports_ui_policy: false, })) } @@ -7969,6 +8198,7 @@ mod tests { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_ui_policy: false, }, telemetry_compute_driver: TelemetryComputeDriver::custom(), driver_process: None, @@ -7987,6 +8217,181 @@ mod tests { } } + fn sandbox_with_explicit_ui(id: &str) -> Sandbox { + let mut sandbox = sandbox_record(id, "ui-policy", SandboxPhase::Provisioning); + sandbox.spec = Some(SandboxSpec { + policy: Some(PublicSandboxPolicy { + ui: Some(UiPolicy::default()), + ..Default::default() + }), + ..Default::default() + }); + sandbox + } + + #[tokio::test] + async fn explicit_ui_policy_rejects_before_unsupported_driver_validation() { + let driver = Arc::new(TestDriver::default()); + let runtime = test_runtime(driver.clone()).await; + + let error = runtime + .validate_sandbox_create(&sandbox_with_explicit_ui("sb-ui-validate")) + .await + .expect_err("an unsupported driver must reject explicit UI policy"); + + assert_eq!(error.code(), Code::InvalidArgument); + assert!(error.message().contains("complete UI policy contract")); + assert_eq!( + driver.validate_create_calls.load(Ordering::Relaxed), + 0, + "gateway capability validation must run before the driver RPC" + ); + } + + #[tokio::test] + async fn explicit_ui_policy_rejects_before_unsupported_driver_create() { + let driver = Arc::new(TestDriver::default()); + let runtime = test_runtime(driver.clone()).await; + + let error = runtime + .create_sandbox(sandbox_with_explicit_ui("sb-ui-create"), None, false) + .await + .expect_err("an internal caller must not bypass UI capability validation"); + + assert_eq!(error.code(), Code::InvalidArgument); + assert_eq!( + driver.create_calls.load(Ordering::Relaxed), + 0, + "unsupported UI policy must fail before provisioning" + ); + } + + #[tokio::test] + async fn effective_ui_policy_rejects_before_unsupported_driver_validation() { + let driver = Arc::new(TestDriver::default()); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record( + "sb-effective-ui", + "effective-ui-policy", + SandboxPhase::Provisioning, + ); + let runtime_inputs = SandboxCreateRuntimeInputs::new(PublicSandboxPolicy { + ui: Some(UiPolicy::default()), + ..Default::default() + }); + + let error = runtime + .validate_sandbox_create_with_runtime_inputs(&sandbox, &runtime_inputs) + .await + .expect_err("an unsupported driver must reject the effective UI policy"); + + assert_eq!(error.code(), Code::InvalidArgument); + assert_eq!(driver.validate_create_calls.load(Ordering::Relaxed), 0); + } + + #[tokio::test] + async fn explicit_ui_policy_reaches_driver_when_capability_is_complete() { + let driver = Arc::new(TestDriver::default()); + let mut runtime = test_runtime(driver.clone()).await; + runtime.driver_info.supports_ui_policy = true; + + runtime + .validate_sandbox_create(&sandbox_with_explicit_ui("sb-ui-supported")) + .await + .expect("a driver advertising complete UI support accepts validation"); + + assert_eq!(driver.validate_create_calls.load(Ordering::Relaxed), 1); + } + + #[tokio::test] + async fn absent_ui_policy_preserves_unsupported_driver_behavior() { + let driver = Arc::new(TestDriver::default()); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record("sb-no-ui", "no-ui-policy", SandboxPhase::Provisioning); + + runtime + .validate_sandbox_create(&sandbox) + .await + .expect("an absent UI section must preserve existing behavior"); + + assert_eq!(driver.validate_create_calls.load(Ordering::Relaxed), 1); + } + + #[tokio::test] + async fn create_runtime_inputs_reach_driver_without_persisting_effective_policy_or_secrets() { + let driver = Arc::new(TestDriver::default()); + let runtime = test_runtime(driver.clone()).await; + + let mut sandbox = sandbox_record( + "sb-provider-inputs", + "provider-inputs", + SandboxPhase::Provisioning, + ); + sandbox.spec = Some(SandboxSpec { + policy: Some(PublicSandboxPolicy { + version: 1, + ..Default::default() + }), + ..Default::default() + }); + let mut runtime_inputs = SandboxCreateRuntimeInputs::new(PublicSandboxPolicy { + version: 2, + ..Default::default() + }); + let launch_authentication = b"protected-launch-material".to_vec(); + runtime_inputs.launch_authentication = Some(launch_authentication.clone()); + + runtime + .create_sandbox_with_runtime_inputs(sandbox, None, false, runtime_inputs) + .await + .expect("create should succeed"); + + let observed_policy_version = { + let created = driver + .created_sandboxes + .lock() + .expect("created sandbox observation lock poisoned"); + assert_eq!( + created[0].spec.as_ref().unwrap().launch_authentication, + launch_authentication, + "launch authentication must reach only the driver" + ); + created[0] + .spec + .as_ref() + .and_then(|spec| spec.policy.as_ref()) + .map(|policy| policy.version) + }; + assert_eq!( + observed_policy_version, + Some(2), + "the driver must receive the effective policy" + ); + + let persisted = runtime + .store + .get_message::("sb-provider-inputs") + .await + .expect("persisted sandbox lookup should succeed") + .expect("sandbox should be persisted"); + assert!( + !persisted + .encode_to_vec() + .windows(launch_authentication.len()) + .any(|bytes| bytes == launch_authentication), + "launch authentication must not enter the public sandbox record" + ); + assert_eq!( + persisted + .spec + .as_ref() + .and_then(|spec| spec.policy.as_ref()) + .map(|policy| policy.version), + Some(1), + "the public sandbox must retain its base policy" + ); + } + async fn test_runtime_with_gateway_managed_lifecycle( driver: SharedComputeDriver, driver_name: &str, @@ -9919,7 +10324,7 @@ mod tests { r#type: "Ready".to_string(), status: "True".to_string(), reason: "AgentRunning".to_string(), - message: "MXC workload is running".to_string(), + message: "Workload is running".to_string(), transition_time: None, }); @@ -12103,6 +12508,133 @@ mod tests { ); } + #[tokio::test] + async fn identity_guarded_delete_rejects_a_different_sandbox_before_mutation() { + let driver = ControlledDriver::new(); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record("sb-current", "sandbox-a", SandboxPhase::Ready); + runtime.store.put_message(&sandbox).await.unwrap(); + + let error = runtime + .delete_sandbox_with_preconditions( + "default", + "sandbox-a", + SandboxDeletePreconditions { + expected_sandbox_id: Some("sb-stale".to_string()), + expected_resource_version: None, + }, + ) + .await + .unwrap_err(); + + assert_eq!(error.code(), Code::Aborted); + assert_eq!(driver.delete_calls(), 0); + let current = runtime + .store + .get_message::("sb-current") + .await + .unwrap() + .unwrap(); + assert_eq!( + SandboxPhase::try_from(current.phase()).unwrap(), + SandboxPhase::Ready + ); + } + + #[tokio::test] + async fn identity_guarded_delete_revalidates_resource_version_under_lifecycle_lock() { + let driver = ControlledDriver::new(); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Ready); + runtime.store.put_message(&sandbox).await.unwrap(); + let current = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + let expected_resource_version = sandbox_resource_version(¤t); + + let delete_gate = runtime.lifecycle_gates.gate_for(sandbox.object_id()); + let delete_guard = delete_gate.lock().await; + let delete_runtime = runtime.clone(); + let delete = tokio::spawn(async move { + delete_runtime + .delete_sandbox_with_preconditions( + "default", + "sandbox-a", + SandboxDeletePreconditions { + expected_sandbox_id: Some("sb-1".to_string()), + expected_resource_version: Some(expected_resource_version), + }, + ) + .await + }); + tokio::time::timeout(Duration::from_secs(1), async { + while Arc::strong_count(&delete_gate) < 2 { + tokio::task::yield_now().await; + } + }) + .await + .expect("guarded delete did not start waiting on the sandbox gate"); + + runtime + .store + .update_message_cas::( + sandbox.object_id(), + expected_resource_version, + |sandbox| sandbox.set_current_policy_version(9), + ) + .await + .unwrap(); + drop(delete_guard); + + let error = delete.await.unwrap().unwrap_err(); + assert_eq!(error.code(), Code::Aborted); + assert_eq!(driver.delete_calls(), 0); + let current = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + assert_eq!(current.current_policy_version(), 9); + assert_eq!( + SandboxPhase::try_from(current.phase()).unwrap(), + SandboxPhase::Ready + ); + } + + #[tokio::test] + async fn identity_guarded_delete_accepts_the_exact_current_identity() { + let driver = ControlledDriver::new(); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Ready); + runtime.store.put_message(&sandbox).await.unwrap(); + let current = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + + let result = runtime + .delete_sandbox_with_preconditions( + "default", + "sandbox-a", + SandboxDeletePreconditions { + expected_sandbox_id: Some(current.object_id().to_string()), + expected_resource_version: Some(sandbox_resource_version(¤t)), + }, + ) + .await + .unwrap(); + + assert!(result.acknowledged()); + assert_eq!(result.sandbox_id, "sb-1"); + assert_eq!(driver.delete_calls(), 1); + } + #[tokio::test] async fn request_cancellation_does_not_cancel_the_delete_worker() { let driver = ControlledDriver::new(); diff --git a/crates/openshell-server/src/credentials.rs b/crates/openshell-server/src/credentials.rs index 901a44f9f3..148acaa176 100644 --- a/crates/openshell-server/src/credentials.rs +++ b/crates/openshell-server/src/credentials.rs @@ -110,6 +110,10 @@ pub trait CredentialDriver: std::fmt::Debug + Send + Sync { pub struct ResolvedProviderCredentials { pub values: HashMap, pub expires_at_ms: HashMap, + /// Keys returned by a credential driver whose effective expiration has + /// already passed. Values stay withheld, but create-time consumers need + /// the identities to fail closed instead of silently omitting credentials. + pub expired_keys: HashSet, } #[derive(Debug, Clone, Copy)] @@ -735,6 +739,7 @@ impl CredentialRuntime { effective_expires_at_ms, "skipping expired handle-backed credential" ); + resolved.expired_keys.insert(credential_key); continue; } resolved diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index 5ac58307a4..9fa1fe4b72 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -287,6 +287,7 @@ impl OpenShell for OpenShellService { .resource_capabilities .as_ref() .map(|resources| public_resource_capabilities(*resources)), + supports_ui_policy: driver.supports_ui_policy, }), }) .collect(); diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 639e6c88ed..0f9dffc7c2 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -1899,6 +1899,20 @@ async fn auto_approve_chunk( Ok(()) } +/// Preserve UI as the sandbox's startup contract when applying a global policy. +/// +/// UI controls are enforced by the compute runtime before the workload starts, +/// so a later global override cannot safely add, remove, or change them. Global +/// policy writes reject their own UI section; this overlay also prevents a +/// global dynamic policy from hiding the UI state that the runtime enforced. +fn preserve_sandbox_startup_ui(policy: &mut ProtoSandboxPolicy, sandbox: &Sandbox) { + policy.ui = sandbox + .spec + .as_ref() + .and_then(|spec| spec.policy.as_ref()) + .and_then(|policy| policy.ui); +} + // TODO: share effective-policy lookup with `load_sandbox_policy` / // `GetSandboxConfig`. They re-implement very similar global-settings and // profile-composition logic; consolidating them is out of scope for the @@ -1932,10 +1946,11 @@ async fn current_effective_policy_from_records( records: &[super::provider::ProviderEnvironmentRecord], ) -> Result { let global_settings = load_global_settings(state.store.as_ref()).await?; - if let Some(global_policy) = decode_policy_from_global_settings(&global_settings)? { + if let Some(mut global_policy) = decode_policy_from_global_settings(&global_settings)? { // A global policy is the complete effective policy. Dormant sandbox // history and specs may predate the current schema, but they must not // prevent the valid global policy from being served. + preserve_sandbox_startup_ui(&mut global_policy, sandbox); return apply_captured_policy_context( provider_policy_context_from_records(catalog, records), global_policy, @@ -2792,9 +2807,10 @@ pub(super) async fn load_sandbox_config( .await .map_err(|e| Status::internal(format!("fetch policy history failed: {e}")))?; - let (mut policy, version, mut policy_hash, policy_source) = if let Some(global_policy) = + let (mut policy, version, mut policy_hash, policy_source) = if let Some(mut global_policy) = global_policy { + preserve_sandbox_startup_ui(&mut global_policy, sandbox); let version = latest .as_ref() .map(|record| u32::try_from(record.version).unwrap_or(0)) @@ -3501,21 +3517,61 @@ pub(super) async fn handle_get_gateway_config( })) } +/// Resolve the effective create-time policy without exporting credential state. +/// Provider refresh and secret delivery remain in the supervisor session. +pub(super) async fn resolve_sandbox_create_runtime_inputs( + state: &ServerState, + sandbox: &Sandbox, +) -> Result { + let sandbox_id = sandbox.object_id(); + let workspace = sandbox.object_workspace(); + let provider_names = sandbox + .spec + .as_ref() + .map(|spec| spec.providers.as_slice()) + .unwrap_or_default(); + let provider_profile_catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), workspace) + .await?; + let provider_records = super::provider::load_provider_environment_records( + state.store.as_ref(), + workspace, + provider_names, + ) + .await?; + let effective_policy = current_effective_policy_from_records( + state, + &provider_profile_catalog, + sandbox, + sandbox_id, + &provider_records, + ) + .await?; + let policy_credential_bindings = + policy_static_credential_endpoint_bindings(Some(&effective_policy))?; + validate_policy_credential_binding_context( + &provider_profile_catalog, + &provider_records, + &effective_policy, + &policy_credential_bindings, + )?; + Ok(crate::compute::SandboxCreateRuntimeInputs::new( + effective_policy, + )) +} + pub(super) async fn handle_get_sandbox_provider_environment( state: &Arc, request: Request, ) -> Result, Status> { let sandbox_id = request.get_ref().sandbox_id.clone(); let supports_static_credential_bindings = request.get_ref().supports_static_credential_bindings; - crate::auth::guard::enforce_sandbox_scope(&request, &sandbox_id)?; + let principal = crate::auth::guard::enforce_sandbox_scope(&request, &sandbox_id)?; drop(request); - let sandbox = state - .store - .get_message::(&sandbox_id) - .await - .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? - .ok_or_else(|| Status::not_found("sandbox not found"))?; + let sandbox = + super::sandbox::fetch_and_authorize_sandbox(state, &principal, &sandbox_id).await?; let environment = load_sandbox_provider_environment(state, &sandbox, supports_static_credential_bindings) .await?; @@ -3775,6 +3831,11 @@ async fn handle_update_config_inner( clear_provider_credentialed_markers(&mut new_policy); validate_no_reserved_provider_policy_keys(&new_policy)?; new_policy = validate_and_canonicalize_policy(new_policy)?; + if new_policy.ui.is_some() { + return Err(Status::invalid_argument( + "UI policy cannot be set globally because it is applied at sandbox startup; configure ui in each sandbox policy", + )); + } validate_policy_safety(&new_policy)?; crate::middleware::validate_policy(state.middleware_registry.as_ref(), &new_policy) .await?; @@ -7006,17 +7067,17 @@ async fn sandbox_policy_merge_validation_data_with_catalog( ) -> Result { let global_settings = load_global_settings(state.store.as_ref()).await?; let composition_enabled = provider_policy_composition_enabled_in(&global_settings)?; - let ProviderPolicyContext { - layers, - credentialed_scopes, - endpointless_provider_names, - } = provider_policy_context_with_catalog( + let records = super::provider::load_provider_environment_records( state.store.as_ref(), - catalog, workspace, provider_names, ) .await?; + let ProviderPolicyContext { + layers, + credentialed_scopes, + endpointless_provider_names, + } = provider_policy_context_from_records(catalog, &records); let provider_layers = if composition_enabled { layers } else { @@ -7027,12 +7088,6 @@ async fn sandbox_policy_merge_validation_data_with_catalog( provider_layer_count = provider_layers.len(), "Composed provider policy and credential context for merge validation" ); - let records = super::provider::load_provider_environment_records( - state.store.as_ref(), - workspace, - provider_names, - ) - .await?; Ok(SandboxPolicyMergeValidationData { provider_layers, catalog: catalog.clone(), @@ -8339,6 +8394,32 @@ mod tests { .expect("test global policy must be present") } + #[tokio::test] + async fn create_runtime_inputs_preserve_base_policy_without_credential_sink() { + let state = test_server_state().await; + let policy = openshell_policy::restrictive_default_policy(); + let sandbox = test_sandbox("create-policy", "create-policy", policy.clone(), Vec::new()); + let inputs = resolve_sandbox_create_runtime_inputs(state.as_ref(), &sandbox) + .await + .expect("driver-independent effective policy"); + assert_eq!(inputs.effective_policy, Some(policy)); + assert!(inputs.launch_authentication.is_none()); + } + + #[tokio::test] + async fn create_runtime_inputs_apply_global_policy_and_preserve_startup_ui() { + let state = test_server_state().await; + let mut global_policy = install_test_global_policy(&state).await; + let mut policy = openshell_policy::restrictive_default_policy(); + policy.ui = Some(openshell_core::proto::UiPolicy::default()); + global_policy.ui = policy.ui; + let sandbox = test_sandbox("create-global", "create-global", policy, Vec::new()); + let inputs = resolve_sandbox_create_runtime_inputs(state.as_ref(), &sandbox) + .await + .expect("global effective policy"); + assert_eq!(inputs.effective_policy, Some(global_policy)); + } + #[tokio::test] async fn list_sandbox_policies_traverses_multiple_pages_exactly_once() { let state = test_server_state().await; @@ -8934,6 +9015,67 @@ mod tests { ); } + #[tokio::test] + async fn global_policy_preserves_each_sandbox_startup_ui_contract() { + use openshell_core::proto::{UiClipboardAccess, UiPolicy}; + + let state = test_server_state().await; + let global_policy = install_test_global_policy(&state).await; + assert!(global_policy.ui.is_none()); + + let sandbox_id = "global-preserves-startup-ui"; + let startup_ui = UiPolicy { + allow_graphical_ui: true, + clipboard: UiClipboardAccess::Read as i32, + allow_input_injection: false, + }; + let mut sandbox_policy = openshell_policy::restrictive_default_policy(); + sandbox_policy.ui = Some(startup_ui); + let sandbox = test_sandbox(sandbox_id, sandbox_id, sandbox_policy, Vec::new()); + state + .store + .put_message(&sandbox) + .await + .expect("store sandbox"); + + let response = handle_get_sandbox_config( + &state, + with_sandbox( + Request::new(GetSandboxConfigRequest { + name: sandbox_id.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + }), + sandbox_id, + ), + ) + .await + .expect("global policy read must preserve startup UI") + .into_inner(); + assert_eq!( + response + .policy + .as_ref() + .and_then(|policy| policy.ui.as_ref()), + Some(&startup_ui) + ); + + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), "default") + .await + .expect("provider profile catalog"); + let effective = current_effective_policy_for_sandbox( + state.as_ref(), + &catalog, + "default", + &sandbox, + sandbox_id, + ) + .await + .expect("effective policy lookup must preserve startup UI"); + assert_eq!(effective.ui.as_ref(), Some(&startup_ui)); + } + #[tokio::test] async fn canonical_mcp_version_order_produces_identical_policy_bytes_and_hashes() { let state = test_server_state().await; @@ -9031,6 +9173,93 @@ mod tests { } } + #[tokio::test] + async fn global_policy_ingress_rejects_startup_only_ui_before_persistence() { + use openshell_core::proto::UiPolicy; + + let state = test_server_state().await; + let mut policy = openshell_policy::restrictive_default_policy(); + policy.ui = Some(UiPolicy::default()); + + let error = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + global: true, + policy: Some(policy), + ..Default::default() + })), + ) + .await + .expect_err("global UI must be rejected before persistence"); + + assert_eq!(error.code(), Code::InvalidArgument); + assert!(error.message().contains("sandbox startup")); + assert!( + state + .store + .get_latest_policy(GLOBAL_POLICY_SANDBOX_ID) + .await + .expect("global policy lookup") + .is_none() + ); + let settings = load_global_settings(state.store.as_ref()) + .await + .expect("global settings lookup"); + assert!(!settings.settings.contains_key(POLICY_SETTING_KEY)); + } + + #[tokio::test] + async fn sandbox_policy_update_accepts_semantically_unchanged_ui_default() { + use openshell_core::proto::{UiClipboardAccess, UiPolicy}; + + let state = test_server_state().await; + let sandbox_id = "ui-default-roundtrip"; + let mut canonical = openshell_policy::restrictive_default_policy(); + canonical.ui = Some(UiPolicy { + allow_graphical_ui: true, + clipboard: UiClipboardAccess::None as i32, + ..Default::default() + }); + state + .store + .put_message(&test_sandbox( + sandbox_id, + sandbox_id, + canonical.clone(), + Vec::new(), + )) + .await + .expect("store sandbox"); + + let mut protobuf_default = canonical; + protobuf_default.ui.as_mut().expect("UI policy").clipboard = + UiClipboardAccess::Unspecified as i32; + handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + sandbox: sandbox_id.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + policy: Some(protobuf_default), + ..Default::default() + })), + ) + .await + .expect("unspecified and none are the same static UI policy"); + + let stored = state + .store + .get_latest_policy(sandbox_id) + .await + .expect("policy history lookup") + .expect("policy revision"); + let persisted = ProtoSandboxPolicy::decode(stored.policy_payload.as_slice()) + .expect("decode persisted policy"); + assert_eq!( + persisted.ui.expect("persisted UI").clipboard, + UiClipboardAccess::None as i32 + ); + } + #[tokio::test] async fn policy_record_identity_global_deduplicates_defaulted_mcp_history() { for (case, legacy_policy) in defaulted_mcp_policy_cases() { @@ -20988,6 +21217,35 @@ mod tests { ); } + #[test] + fn policy_hash_distinguishes_ui_absence_presence_and_values() { + use openshell_core::proto::{UiClipboardAccess, UiPolicy}; + + let absent = ProtoSandboxPolicy::default(); + let explicit_deny = ProtoSandboxPolicy { + ui: Some(UiPolicy::default()), + ..Default::default() + }; + let clipboard_read = ProtoSandboxPolicy { + ui: Some(UiPolicy { + clipboard: UiClipboardAccess::Read as i32, + ..Default::default() + }), + ..Default::default() + }; + + assert_ne!( + deterministic_policy_hash(&absent), + deterministic_policy_hash(&explicit_deny), + "an explicitly present deny-only UI block remains hash-significant" + ); + assert_ne!( + deterministic_policy_hash(&explicit_deny), + deterministic_policy_hash(&clipboard_read), + "UI capability changes must produce a new policy hash" + ); + } + #[test] fn policy_hash_is_stable_across_middleware_config_field_insertion_order() { use prost_types::{Struct, Value, value::Kind}; @@ -23446,6 +23704,22 @@ mod tests { "handle_get_sandbox_config must return NotFound, not PermissionDenied" ); + // --- handle_get_sandbox_provider_environment --- + let err = handle_get_sandbox_provider_environment( + &state, + non_member_request(GetSandboxProviderEnvironmentRequest { + sandbox_id: "sandbox-other".into(), + supports_static_credential_bindings: true, + }), + ) + .await + .unwrap_err(); + assert_eq!( + err.code(), + Code::NotFound, + "handle_get_sandbox_provider_environment must hide cross-workspace sandboxes" + ); + // --- handle_get_sandbox_logs --- let err = handle_get_sandbox_logs( &state, diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index cc2395fe7d..903127b95a 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -75,6 +75,11 @@ pub(super) struct ProviderEnvironment { pub static_credential_bindings: HashMap, pub static_credential_keys: HashSet, pub files: HashMap, + /// Static credential keys withheld because they were already expired at + /// resolution time. Excluded from `environment`/`static_credential_keys` + /// like any other withheld key, but tracked separately from keys that + /// never had an injectable credential. + pub expired_static_keys: HashSet, } /// Immutable provider records used to build one provider-environment response. @@ -1131,6 +1136,7 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin let mut files = HashMap::new(); let mut file_env_keys = HashSet::new(); let mut readiness_reason = openshell_core::proto::ProviderReadinessReason::Unspecified; + let mut expired_static_keys = HashSet::new(); let now_ms = crate::persistence::current_time_ms(); validate_provider_environment_records_unique_at(store, catalog, records, now_ms).await?; let registry = openshell_providers::ProviderRegistry::new(); @@ -1257,6 +1263,7 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin ); readiness_reason = openshell_core::proto::ProviderReadinessReason::CredentialExpired; + expired_static_keys.insert(key.clone()); continue; } expires.entry(key.clone()).or_insert(expires_at_ms); @@ -1367,6 +1374,22 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin } } + // The credential runtime withholds expired handle-backed values. Keep + // the identity of otherwise injectable keys in the resolver result. + for key in resolved_refs.expired_keys { + if accepted_stored_credential_keys + .as_ref() + .is_some_and(|accepted| !accepted.contains(&key)) + || is_non_injectable_provider_credential(provider, &key) + || broker_only_credential_keys.contains(&key) + || has_no_usable_endpoint + || !is_valid_env_key(&key) + { + continue; + } + expired_static_keys.insert(key); + } + // Build each provider's emitted environment independently so another // provider's earlier output cannot change how this provider classifies // or populates its own keys. Cross-provider credential/config @@ -1433,6 +1456,7 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin static_credential_bindings, static_credential_keys, files, + expired_static_keys, }) } @@ -11858,6 +11882,49 @@ mod tests { ); } + #[tokio::test] + async fn resolve_provider_env_preserves_expired_handle_key_for_create_time_rejection() { + let store = test_store().await; + let config = openshell_core::Config::new(None).with_credential_drivers(["test-static"]); + let credentials = crate::credentials::CredentialRuntime::from_config(&config).unwrap(); + let catalog = ProviderProfileSources::with_default_sources() + .snapshot_catalog(&store, "default") + .await + .unwrap(); + let mut provider = provider_with_credential_value( + "github-expired", + "github", + "GITHUB_TOKEN", + "github-token", + ); + provider.credential_expiration_times.insert( + "GITHUB_TOKEN".to_string(), + ts(crate::persistence::current_time_ms() - 1), + ); + create_provider_record_validating( + &store, + "default", + &catalog, + provider, + Some(&credentials), + ) + .await + .unwrap(); + + let result = resolve_provider_environment_with_credentials( + &store, + &catalog, + "default", + &["github-expired".to_string()], + &credentials, + ) + .await + .unwrap(); + + assert!(!result.contains_key("GITHUB_TOKEN")); + assert!(result.expired_static_keys.contains("GITHUB_TOKEN")); + } + #[tokio::test] async fn resolve_provider_env_skips_expired_credentials_and_returns_expiry_metadata() { let store = test_store().await; diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 074b2590e0..e1fa354eb4 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -14,6 +14,7 @@ use crate::auth::workspace_authz::{ AuthorizedWorkspaceScope, MinWorkspaceRole, authorize_list_workspace_selector, authorize_sandbox_workspace, authorize_workspace, }; +use crate::compute::SandboxDeletePreconditions; use crate::pagination::Pagination; use crate::persistence::{ ObjectLabels, ObjectListQuery, ObjectType, WriteCondition, generate_name, @@ -158,6 +159,37 @@ impl Drop for WatchSandboxStream { } } +/// Fetch a runtime sandbox ID and authorize its persisted workspace without +/// revealing whether an inaccessible object exists. +pub(super) async fn fetch_and_authorize_sandbox( + state: &ServerState, + principal: &crate::auth::principal::Principal, + sandbox_id: &str, +) -> Result { + let sandbox = state + .store + .get_message::(sandbox_id) + .await + .map_err(|error| Status::internal(format!("fetch sandbox failed: {error}")))? + .ok_or_else(|| Status::not_found("sandbox not found"))?; + authorize_sandbox_workspace( + &state.store, + &state.admin_role, + principal, + sandbox.object_workspace(), + MinWorkspaceRole::User, + ) + .await + .map_err(|error| { + if error.code() == tonic::Code::PermissionDenied { + Status::not_found("sandbox not found") + } else { + error + } + })?; + Ok(sandbox) +} + /// Resolve a public sandbox name and authorize its persisted workspace. /// Missing and unauthorized objects deliberately share one response so names /// cannot be used as an existence oracle. @@ -629,9 +661,12 @@ async fn handle_create_sandbox_inner( ) .await?; + let mut runtime_inputs = + super::policy::resolve_sandbox_create_runtime_inputs(state.as_ref(), &sandbox).await?; + state .compute - .validate_sandbox_create(&sandbox) + .validate_sandbox_create_with_runtime_inputs(&sandbox, &runtime_inputs) .await .map_err(|status| { warn!(error = %status, "Rejecting sandbox create request"); @@ -661,12 +696,13 @@ async fn handle_create_sandbox_inner( .map_err(|error| Status::internal(format!("encode launch authentication: {error}"))) }) .transpose()?; + runtime_inputs.launch_authentication = launch_authentication; - let sandbox = Box::pin(state.compute.create_sandbox_authenticated_with_guards( + let sandbox = Box::pin(state.compute.create_sandbox_with_runtime_inputs_and_guards( sandbox, sandbox_token, - launch_authentication, await_main_process_attachment, + runtime_inputs, sandbox_lifecycle_guard, sandbox_sync_guard, )) @@ -1634,10 +1670,18 @@ async fn handle_delete_sandbox_inner( let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) .await? .name; - + // Main's canonical public delete request contains no caller-provided + // identity/version fields. The compute layer still binds the resolved + // object ID and lifecycle guards before committing deletion. + let preconditions = SandboxDeletePreconditions::default(); let result = state .compute - .delete_sandbox_allow_missing(&workspace, &name, req.allow_missing) + .delete_sandbox_allow_missing_with_preconditions( + &workspace, + &name, + req.allow_missing, + preconditions, + ) .await?; if !result.sandbox_id.is_empty() { state.telemetry.end_sandbox_session(&result.sandbox_id); @@ -2592,8 +2636,8 @@ pub(super) async fn handle_forward_tcp( .await .map_err(|e| Status::unavailable(format!("supervisor relay failed: {e}")))?; - let sandbox_id = sandbox.object_id().to_string(); let (tx, rx) = mpsc::channel::>(256); + let sandbox_id = sandbox.object_id().to_string(); tokio::spawn(async move { let _connection_guard = connection_guard; let Some(relay_stream) = @@ -2776,13 +2820,15 @@ fn validate_tcp_target_parts(host: &str, _port: u32) -> Result { } } -async fn bridge_forward_tcp_stream( +async fn bridge_forward_tcp_stream( mut inbound: tonic::Streaming, - relay_stream: tokio::io::DuplexStream, + relay_stream: S, tx: mpsc::Sender>, sandbox_id: &str, channel_id: &str, -) { +) where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + 'static, +{ let (mut relay_read, mut relay_write) = tokio::io::split(relay_stream); let sandbox_id_in = sandbox_id.to_string(); @@ -5506,6 +5552,68 @@ mod tests { ); } + #[tokio::test] + async fn internal_delete_rejects_expected_identity_drift_before_mutation() { + let state = test_server_state().await; + let mut sandbox = test_sandbox("guarded-delete", Vec::new()); + sandbox.metadata.as_mut().unwrap().id = "sb-current".to_string(); + state.store.put_message(&sandbox).await.unwrap(); + + let error = state + .compute + .delete_sandbox_with_preconditions( + "default", + "guarded-delete", + SandboxDeletePreconditions { + expected_sandbox_id: Some("sb-stale".to_string()), + expected_resource_version: None, + }, + ) + .await + .unwrap_err(); + + assert_eq!(error.code(), tonic::Code::Aborted); + assert!( + state + .store + .get_message::("sb-current") + .await + .unwrap() + .is_some() + ); + } + + #[tokio::test] + async fn internal_delete_rejects_resource_version_without_immutable_identity() { + let state = test_server_state().await; + let mut sandbox = test_sandbox("guarded-delete", Vec::new()); + sandbox.metadata.as_mut().unwrap().id = "sb-current".to_string(); + state.store.put_message(&sandbox).await.unwrap(); + + let error = state + .compute + .delete_sandbox_with_preconditions( + "default", + "guarded-delete", + SandboxDeletePreconditions { + expected_sandbox_id: None, + expected_resource_version: Some(17), + }, + ) + .await + .unwrap_err(); + + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert!( + state + .store + .get_message::("sb-current") + .await + .unwrap() + .is_some() + ); + } + #[tokio::test] async fn attach_sandbox_provider_persists_current_provider_list() { let state = test_server_state().await; diff --git a/crates/openshell-server/src/grpc/validation.rs b/crates/openshell-server/src/grpc/validation.rs index f72c571ba5..c938932c3a 100644 --- a/crates/openshell-server/src/grpc/validation.rs +++ b/crates/openshell-server/src/grpc/validation.rs @@ -10,7 +10,7 @@ use openshell_core::proto::{ CredentialHandle, ExecSandboxRequest, Provider, SandboxPolicy as ProtoSandboxPolicy, - SandboxRestartPolicy, SandboxSpec, SandboxTemplate, + SandboxRestartPolicy, SandboxSpec, SandboxTemplate, UiClipboardAccess, }; use openshell_core::rpc_error::invalid_argument; use prost::Message; @@ -1030,7 +1030,7 @@ pub(super) fn validate_no_reserved_provider_policy_keys( Ok(()) } -/// Validate that static policy fields (filesystem, landlock, process) haven't changed +/// Validate that static policy fields (filesystem, landlock, process, UI) haven't changed /// from the baseline (version 1) policy. pub(super) fn validate_static_fields_unchanged( baseline: &ProtoSandboxPolicy, @@ -1054,6 +1054,18 @@ pub(super) fn validate_static_fields_unchanged( "process policy cannot be changed on a live sandbox (applied at startup)", )); } + let mut baseline_ui = baseline.ui; + let mut new_ui = new.ui; + for ui in [&mut baseline_ui, &mut new_ui].into_iter().flatten() { + if ui.clipboard == UiClipboardAccess::Unspecified as i32 { + ui.clipboard = UiClipboardAccess::None as i32; + } + } + if baseline_ui != new_ui { + return Err(Status::invalid_argument( + "UI policy cannot be changed on a live sandbox (applied at startup)", + )); + } Ok(()) } @@ -2401,6 +2413,33 @@ mod tests { assert!(result.unwrap_err().message().contains("include_workdir")); } + #[test] + fn validate_static_fields_rejects_ui_presence_or_value_change() { + use openshell_core::proto::{UiClipboardAccess, UiPolicy}; + + let absent = ProtoSandboxPolicy::default(); + let deny = ProtoSandboxPolicy { + ui: Some(UiPolicy::default()), + ..Default::default() + }; + let allow_clipboard = ProtoSandboxPolicy { + ui: Some(UiPolicy { + clipboard: UiClipboardAccess::Read as i32, + ..Default::default() + }), + ..Default::default() + }; + + let presence_error = validate_static_fields_unchanged(&absent, &deny) + .expect_err("adding explicit UI policy must be static"); + assert!(presence_error.message().contains("UI policy")); + + let value_error = validate_static_fields_unchanged(&deny, &allow_clipboard) + .expect_err("changing UI policy must be static"); + assert!(value_error.message().contains("UI policy")); + assert!(validate_static_fields_unchanged(&deny, &deny).is_ok()); + } + // ---- Exec validation ---- #[test] diff --git a/crates/openshell-server/src/storage_proto.rs b/crates/openshell-server/src/storage_proto.rs index e6d8730aa0..9fdcf868bd 100644 --- a/crates/openshell-server/src/storage_proto.rs +++ b/crates/openshell-server/src/storage_proto.rs @@ -129,11 +129,11 @@ mod tests { // Service authorization also extends both schemas additively. Legacy // payloads retain the safe Strip default. const PUBLIC_RPC_SCHEMA_SHA256: &str = - "2e156c6ad3c8eb51bcd30dc13b173fe339b38207a1b1f98f7be2e0cad8e3bd45"; + "a43807338a45b74e4871332ff80eca51b4f7410fb98015deb9678e6d33937703"; const DURABLE_SCHEMA_SHA256: &str = - "38165d9d76f49fcfe98a12f241e032838a2376c1d1a87ea2796fd33b9b1a3541"; + "4cf05ad176542f2511794d7a05c8b8c9db988c6cb57cd06d0a26243c45b4c20f"; const PUBLIC_DURABLE_OVERLAP_SHA256: &str = - "761dea31a521b0650840fe2a823ad6e36a265ed323ba4506889781d630df0ee3"; + "5f5165548d2ec333689fc88ab652af1977760bb976007a634e3ed30f28782424"; // A persisted Sandbox without endpoint status retains its lifecycle fields; // the absent repeated field decodes empty and needs no database rewrite. const SANDBOX_WITHOUT_ENDPOINT_STATUS: &str = "0a1e0a0a73616e64626f782d6964120773616e64626f783a0764656661756c741a2b0a0773616e64626f782a0d0a05526561647912045472756530023807420d73757065727669736f722d6964"; @@ -598,9 +598,9 @@ mod tests { overlap_hash.as_str(), ), ( - (306, 27), - (93, 21), - (81, 21), + (307, 28), + (94, 22), + (82, 22), PUBLIC_RPC_SCHEMA_SHA256, DURABLE_SCHEMA_SHA256, PUBLIC_DURABLE_OVERLAP_SHA256 @@ -729,6 +729,7 @@ mod tests { PolicyRevisionPayload::decode(legacy_bytes(V0_0_116_POLICY_PAYLOAD).as_slice()) .expect("legacy policy payload must decode"); assert!(policy_payload.policy.is_some()); + assert!(policy_payload.policy.as_ref().unwrap().ui.is_none()); assert_eq!(policy_payload.hash, "sha256"); assert_eq!(policy_payload.load_error, "none"); assert_eq!(policy_payload.loaded_at_ms, 300); diff --git a/crates/openshell-server/src/test_support.rs b/crates/openshell-server/src/test_support.rs index dcbc2e1cf9..c533f08795 100644 --- a/crates/openshell-server/src/test_support.rs +++ b/crates/openshell-server/src/test_support.rs @@ -154,6 +154,7 @@ impl FakeComputeDriver { gateway_manages_lifecycle: false, supports_sandbox_authentication: false, driver_reports_runtime_readiness: false, + supports_ui_policy: false, resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, diff --git a/crates/openshell-server/src/tracing_setup.rs b/crates/openshell-server/src/tracing_setup.rs index 3a74edd72f..589c4f1aba 100644 --- a/crates/openshell-server/src/tracing_setup.rs +++ b/crates/openshell-server/src/tracing_setup.rs @@ -93,7 +93,7 @@ pub fn install( ) }, ); - let (jsonl_layer, jsonl_dir) = build_ocsf_jsonl_layer(gateway.compute_driver()); + let (jsonl_layer, jsonl_dir) = build_ocsf_jsonl_layer(); // Keep the audit sink independent from the operator's diagnostic log // level. An explicit JSONL opt-in must keep every OCSF event even when the @@ -161,8 +161,7 @@ pub fn install( /// Build the OCSF JSONL audit layer for the gateway, plus the directory it /// writes into (for a one-line startup log). Returns `(None, None)` when -/// the target is not Windows, the selected compute driver is not MXC, the sink -/// was not explicitly enabled through `OPENSHELL_OCSF_JSON`, or the target +/// the sink was not explicitly enabled through `OPENSHELL_OCSF_JSON`, or the target /// directory/appender cannot be opened. /// /// The appender is *synchronous* (not wrapped in `tracing_appender::non_blocking`) @@ -171,31 +170,26 @@ pub fn install( /// its non-blocking guard on graceful shutdown), the gateway's ETW capture path /// can be force-killed by the harness, and we do not want to lose the tail of the /// audit trail. -#[cfg(not(target_os = "windows"))] -fn build_ocsf_jsonl_layer( - _compute_driver: Option<&str>, -) -> ( +fn build_ocsf_jsonl_layer() -> ( Option>, Option, ) { - // The gateway-local JSONL sink belongs to the Windows/MXC ETW path. A - // cross-platform sink needs an explicit storage and configuration contract. - (None, None) + let requested = std::env::var("OPENSHELL_OCSF_JSON").ok(); + if !ocsf_jsonl_requested(requested.as_deref()) { + return (None, None); + } + + open_ocsf_jsonl_layer(ocsf_log_dir()) } -#[cfg(target_os = "windows")] -fn build_ocsf_jsonl_layer( - compute_driver: Option<&str>, +/// Portable sink construction; environment and platform defaults are resolved +/// separately so operators and tests can supply an explicit directory. +fn open_ocsf_jsonl_layer( + dir: std::path::PathBuf, ) -> ( Option>, Option, ) { - let requested = std::env::var("OPENSHELL_OCSF_JSON").ok(); - if !mxc_ocsf_jsonl_requested(compute_driver, requested.as_deref()) { - return (None, None); - } - - let dir = ocsf_log_dir(); if let Err(e) = std::fs::create_dir_all(&dir) { eprintln!( "openshell: could not create OCSF JSONL log dir {}: {e}", @@ -222,24 +216,21 @@ fn build_ocsf_jsonl_layer( } } -/// Whether this gateway explicitly requested the Windows/MXC JSONL sink. +/// Whether this gateway explicitly requested the JSONL sink. /// Unknown values fail closed so a typo cannot unexpectedly retain audit data. -#[cfg(any(target_os = "windows", test))] -fn mxc_ocsf_jsonl_requested(compute_driver: Option<&str>, value: Option<&str>) -> bool { - compute_driver == Some("mxc") - && value.is_some_and(|value| { - matches!( - value.trim().to_ascii_lowercase().as_str(), - "1" | "true" | "on" | "yes" - ) - }) +fn ocsf_jsonl_requested(value: Option<&str>) -> bool { + value.is_some_and(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "on" | "yes" + ) + }) } /// Resolve the directory for the OCSF JSONL audit file. /// /// Precedence: `OPENSHELL_OCSF_LOG_DIR` (harness / operator override) then -/// `%PROGRAMDATA%\OpenShell\logs`. -#[cfg(target_os = "windows")] +/// the platform's gateway audit directory. fn ocsf_log_dir() -> std::path::PathBuf { if let Ok(dir) = std::env::var("OPENSHELL_OCSF_LOG_DIR") { let trimmed = dir.trim(); @@ -247,10 +238,7 @@ fn ocsf_log_dir() -> std::path::PathBuf { return std::path::PathBuf::from(trimmed); } } - if let Ok(pd) = std::env::var("ProgramData") { - return std::path::PathBuf::from(pd).join("OpenShell").join("logs"); - } - std::env::temp_dir().join("openshell").join("logs") + openshell_core::paths::gateway_audit_log_dir() } #[cfg(test)] @@ -264,7 +252,45 @@ mod tests { use tracing_subscriber::EnvFilter; use tracing_subscriber::prelude::*; - use super::mxc_ocsf_jsonl_requested; + use super::ocsf_jsonl_requested; + + #[test] + fn portable_gateway_audit_sink_writes_jsonl() { + let root = tempfile::tempdir().unwrap(); + let directory = root.path().join("audit"); + let (layer, resolved) = super::open_ocsf_jsonl_layer(directory.clone()); + assert_eq!(resolved, Some(directory.clone())); + let subscriber = tracing_subscriber::registry().with(layer.expect("open audit sink")); + let ctx = EventContext { + sandbox_id: "portable-audit".into(), + sandbox_name: "portable-audit".into(), + container_image: String::new(), + hostname: "gateway-host".into(), + product_version: openshell_core::VERSION.into(), + proxy_ip: "127.0.0.1".parse().unwrap(), + proxy_port: 0, + origin: openshell_ocsf::EventOrigin::Supervisor, + }; + tracing::subscriber::with_default(subscriber, || { + emit_ocsf_event_routed("portable-audit", AppLifecycleBuilder::new(&ctx).build()); + }); + let files: Vec<_> = std::fs::read_dir(directory).unwrap().collect(); + assert_eq!(files.len(), 1); + let contents = std::fs::read_to_string(files[0].as_ref().unwrap().path()).unwrap(); + let records: Vec<_> = contents.lines().collect(); + assert_eq!(records.len(), 1); + assert!(serde_json::from_str::(records[0]).is_ok()); + } + + #[test] + fn portable_gateway_audit_sink_rejects_a_file_as_directory() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("file"); + std::fs::write(&path, b"not a directory").unwrap(); + let (layer, directory) = super::open_ocsf_jsonl_layer(path); + assert!(layer.is_none()); + assert!(directory.is_none()); + } #[derive(Clone)] struct SharedWriter(Arc>>); @@ -316,33 +342,20 @@ mod tests { #[test] fn gateway_ocsf_jsonl_requires_explicit_opt_in() { - assert!(!mxc_ocsf_jsonl_requested(Some("mxc"), None)); - assert!(!mxc_ocsf_jsonl_requested(Some("mxc"), Some(""))); - assert!(!mxc_ocsf_jsonl_requested(Some("mxc"), Some("enabled"))); + assert!(!ocsf_jsonl_requested(None)); + assert!(!ocsf_jsonl_requested(Some(""))); + assert!(!ocsf_jsonl_requested(Some("enabled"))); for value in ["0", "false", "FALSE", " off ", "no"] { - assert!(!mxc_ocsf_jsonl_requested(Some("mxc"), Some(value))); + assert!(!ocsf_jsonl_requested(Some(value))); } for value in ["1", "true", "TRUE", " on ", "yes"] { assert!( - mxc_ocsf_jsonl_requested(Some("mxc"), Some(value)), + ocsf_jsonl_requested(Some(value)), "expected {value:?} to opt in" ); } } - - #[test] - fn gateway_ocsf_jsonl_rejects_non_mxc_drivers() { - for driver in [ - None, - Some("docker"), - Some("kubernetes"), - Some("podman"), - Some("vm"), - ] { - assert!(!mxc_ocsf_jsonl_requested(driver, Some("1"))); - } - } } #[cfg(test)] diff --git a/crates/openshell-supervisor-network/src/host.rs b/crates/openshell-supervisor-network/src/host.rs deleted file mode 100644 index 7e29b3d0ed..0000000000 --- a/crates/openshell-supervisor-network/src/host.rs +++ /dev/null @@ -1,378 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Host-side proxy startup for compute drivers that do not run the Linux -//! in-sandbox supervisor. -//! -//! MXC uses this on Windows: MXC's `network.proxy` redirects sandbox egress to a -//! per-sandbox loopback listener in the gateway process, and this module starts -//! the existing `OpenShell` CONNECT proxy against the trimmed network-only -//! `SandboxPolicy`. - -use std::net::SocketAddr; -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::atomic::AtomicU32; - -use miette::Result; -use openshell_core::activity::ActivitySender; -use openshell_core::denial::DenialEvent; -use openshell_core::policy::ProxyPolicy; -use openshell_core::proposals::AgentProposals; -use openshell_core::proto::SandboxPolicy as ProtoSandboxPolicy; -use openshell_core::provider_credentials::ProviderCredentialState; -use openshell_isolation_interface::contract::{BinaryIdentity, ExecutableIdentity, Sha256Digest}; -use openshell_ocsf::{ - ConfigStateChangeBuilder, SeverityId, StateId, StatusId, ctx::ctx as ocsf_ctx, ocsf_emit, -}; -use tokio::sync::mpsc::UnboundedSender; - -use crate::identity::BinaryIdentityCache; -use crate::l7::tls::{ - CertCache, ProxyTlsState, SandboxCa, build_upstream_client_config, read_system_ca_bundle, - write_ca_files, -}; -use crate::opa::OpaEngine; -use crate::policy_local::PolicyLocalContext; -use crate::proxy::ProxyHandle; - -/// Configuration for a host-side `OpenShell` CONNECT proxy. -pub struct HostProxyConfig { - /// Exact socket the compute driver will redirect sandbox egress to. - pub bind_addr: SocketAddr, - /// Network-only policy produced by the compute driver's policy split. - pub policy: ProtoSandboxPolicy, - /// Static process identity used when the platform cannot recover the - /// socket-owning sandbox process. Policy binaries must match this path for - /// L4/L7 allow rules to pass. - pub binary_path: PathBuf, - pub sandbox_id: Option, - pub sandbox_name: Option, - pub openshell_endpoint: Option, - pub provider_credentials: Option, - /// Shared feature state for the policy.local agent proposal surface. - pub agent_proposals: AgentProposals, - pub denial_tx: Option>, - pub activity_tx: Option, -} - -/// RAII handle for a host-side proxy. Dropping it aborts the proxy accept loop. -pub struct HostProxyHandle { - proxy: ProxyHandle, - ca_file_paths: Option<(PathBuf, PathBuf)>, - #[allow(dead_code)] - tls_dir: Option, - pub policy_local_ctx: Arc, -} - -impl HostProxyHandle { - #[must_use] - pub const fn http_addr(&self) -> Option { - self.proxy.http_addr() - } - - #[must_use] - pub fn ca_file_paths(&self) -> Option<(PathBuf, PathBuf)> { - self.ca_file_paths.clone() - } -} - -/// Start a host-side proxy for one sandbox. -/// -/// Linux supervisor mode should continue to use `run::run_networking`; this API -/// is for host-side compute-driver integrations such as Windows MXC. -pub async fn start_host_proxy(config: HostProxyConfig) -> Result { - if !config.bind_addr.ip().is_loopback() { - return Err(miette::miette!( - "host proxy bind address must be loopback-only: {}", - config.bind_addr - )); - } - if !config.policy.network_middlewares.is_empty() { - return Err(miette::miette!( - "host proxy cannot enforce network_middlewares without a middleware service registry; refusing to start with {} configured middleware(s)", - config.policy.network_middlewares.len() - )); - } - - let engine = Arc::new(OpaEngine::from_proto(&config.policy)?); - let (_workspace_tx, workspace_rx) = tokio::sync::watch::channel(String::new()); - let policy_local_ctx = Arc::new(PolicyLocalContext::new( - Some(config.policy.clone()), - config.openshell_endpoint.clone(), - config - .sandbox_name - .clone() - .or_else(|| config.sandbox_id.clone()), - config.agent_proposals, - workspace_rx, - )); - let (_ready_tx, ready_rx) = tokio::sync::watch::channel(true); - let proxy_policy = ProxyPolicy { - http_addr: Some(config.bind_addr), - }; - let upstream_proxy_args = crate::upstream_proxy::UpstreamProxyArgs::default(); - let (tls_state, ca_file_paths, tls_dir) = match tempfile::Builder::new() - .prefix("openshell-mxc-tls-") - .tempdir() - { - Ok(tls_dir) => match SandboxCa::generate() { - Ok(ca) => { - let system_ca_bundle = read_system_ca_bundle(); - match write_ca_files(&ca, tls_dir.path(), &system_ca_bundle) { - Ok(paths) => match build_upstream_client_config(&system_ca_bundle) { - Ok(upstream_config) => { - let cert_cache = CertCache::new(ca); - let state = Arc::new(ProxyTlsState::new(cert_cache, upstream_config)); - ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) - .severity(SeverityId::Informational) - .status(StatusId::Success) - .state(StateId::Enabled, "enabled") - .message( - "Host proxy TLS termination enabled: ephemeral CA generated" - ) - .build() - ); - (Some(state), Some(paths), Some(tls_dir)) - } - Err(e) => { - ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) - .severity(SeverityId::High) - .status(StatusId::Failure) - .state(StateId::Disabled, "disabled") - .message(format!( - "Failed to build host proxy upstream TLS config, TLS termination disabled: {e}" - )) - .build() - ); - (None, None, Some(tls_dir)) - } - }, - Err(e) => { - ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) - .severity(SeverityId::High) - .status(StatusId::Failure) - .state(StateId::Disabled, "disabled") - .message(format!( - "Failed to write host proxy CA files, TLS termination disabled: {e}" - )) - .build() - ); - (None, None, Some(tls_dir)) - } - } - } - Err(e) => { - ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) - .severity(SeverityId::High) - .status(StatusId::Failure) - .state(StateId::Disabled, "disabled") - .message(format!( - "Failed to generate host proxy ephemeral CA, TLS termination disabled: {e}" - )) - .build() - ); - (None, None, Some(tls_dir)) - } - }, - Err(e) => { - ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) - .severity(SeverityId::High) - .status(StatusId::Failure) - .state(StateId::Disabled, "disabled") - .message(format!( - "Failed to create host proxy TLS trust directory, TLS termination disabled: {e}" - )) - .build() - ); - (None, None, None) - } - }; - let binary_digest: Sha256Digest = crate::procfs::file_sha256(&config.binary_path)? - .parse() - .map_err(|error| miette::miette!("hash host proxy binary identity: {error}"))?; - let direct_listener_identity = BinaryIdentity { - executable: ExecutableIdentity { - path: config.binary_path, - digest: Some(binary_digest), - }, - ancestors: Vec::new(), - cmdline_paths: Vec::new(), - }; - let proxy = ProxyHandle::start_with_bind_addr( - &proxy_policy, - Some(config.bind_addr), - engine, - Arc::new(BinaryIdentityCache::new()), - Arc::new(AtomicU32::new(0)), - tls_state, - config.provider_credentials, - Some(policy_local_ctx.clone()), - config.denial_tx, - config.activity_tx, - None, - ready_rx, - &upstream_proxy_args, - None, - None, - None, - Some(direct_listener_identity), - ) - .await?; - - Ok(HostProxyHandle { - proxy, - ca_file_paths, - tls_dir, - policy_local_ctx, - }) -} - -#[cfg(test)] -mod tests { - use std::time::Duration; - - use openshell_core::proposals::AgentProposals; - use openshell_core::proto::{ - MiddlewareEndpointSelector, NetworkMiddlewareConfig, SandboxPolicy as ProtoSandboxPolicy, - }; - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - use tokio::net::TcpStream; - - use super::*; - - fn test_config(bind_addr: SocketAddr, binary_path: PathBuf) -> HostProxyConfig { - HostProxyConfig { - bind_addr, - policy: ProtoSandboxPolicy { - version: 1, - ..Default::default() - }, - binary_path, - sandbox_id: Some("sandbox-123".to_string()), - sandbox_name: Some("agent-box".to_string()), - openshell_endpoint: None, - provider_credentials: None, - agent_proposals: AgentProposals::new(true), - denial_tx: None, - activity_tx: None, - } - } - - #[tokio::test] - async fn rejects_non_loopback_bind_addr() { - let result = start_host_proxy(test_config( - ([192, 0, 2, 1], 0).into(), - PathBuf::from("missing-agent.exe"), - )) - .await; - - let Err(err) = result else { - panic!("host proxy should reject non-loopback bind addresses"); - }; - assert!( - err.to_string().contains("loopback-only"), - "unexpected error: {err}" - ); - } - - #[tokio::test] - async fn rejects_middleware_policy_without_registry() { - let mut config = test_config( - ([127, 0, 0, 1], 0).into(), - PathBuf::from("missing-agent.exe"), - ); - config.policy.network_middlewares.insert( - "redactor".into(), - NetworkMiddlewareConfig { - name: "redactor".into(), - middleware: "openshell/regex".into(), - on_error: "fail_closed".into(), - endpoints: Some(MiddlewareEndpointSelector { - include: vec!["api.example.com".into()], - exclude: Vec::new(), - }), - ..Default::default() - }, - ); - - let Err(error) = start_host_proxy(config).await else { - panic!("host proxy must reject middleware without a registry"); - }; - assert!( - error - .to_string() - .contains("cannot enforce network_middlewares"), - "unexpected error: {error}" - ); - } - - #[tokio::test] - async fn starts_loopback_proxy_and_serves_policy_local() { - let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - let binary = tempfile::NamedTempFile::new().unwrap(); - std::fs::write(binary.path(), b"agent").unwrap(); - - let handle = start_host_proxy(test_config( - ([127, 0, 0, 1], 0).into(), - binary.path().to_path_buf(), - )) - .await - .unwrap(); - - let addr = handle.http_addr().expect("proxy should report bound addr"); - assert!(addr.ip().is_loopback()); - assert_ne!(addr.port(), 0); - let (ca_cert, bundle) = handle.ca_file_paths().expect("CA paths"); - assert!(ca_cert.exists(), "standalone CA cert should exist"); - assert!(bundle.exists(), "combined CA bundle should exist"); - assert!( - std::fs::read_to_string(ca_cert) - .unwrap() - .contains("BEGIN CERTIFICATE") - ); - assert!( - std::fs::read_to_string(bundle) - .unwrap() - .contains("BEGIN CERTIFICATE") - ); - - let mut client = TcpStream::connect(addr).await.unwrap(); - client - .write_all( - b"GET http://policy.local/v1/policy/current HTTP/1.1\r\n\ - Host: policy.local\r\n\ - Connection: close\r\n\ - \r\n", - ) - .await - .unwrap(); - - let mut response = Vec::new(); - tokio::time::timeout(Duration::from_secs(2), client.read_to_end(&mut response)) - .await - .unwrap() - .unwrap(); - - let response = String::from_utf8(response).unwrap(); - assert!( - response.starts_with("HTTP/1.1 200 OK"), - "unexpected response: {response}" - ); - let (_, body) = response.split_once("\r\n\r\n").expect("response body"); - let body: serde_json::Value = serde_json::from_str(body).unwrap(); - assert_eq!(body["format"], "yaml"); - assert!( - body["policy_yaml"] - .as_str() - .unwrap_or_default() - .contains("version: 1"), - "unexpected policy payload: {body}" - ); - } -} diff --git a/crates/openshell-supervisor-network/src/identity.rs b/crates/openshell-supervisor-network/src/identity.rs index 5fb123a1c0..99191e426d 100644 --- a/crates/openshell-supervisor-network/src/identity.rs +++ b/crates/openshell-supervisor-network/src/identity.rs @@ -293,6 +293,14 @@ mod tests { value.repeat(32).parse().unwrap() } + fn native_identity_path(path: &str) -> PathBuf { + if cfg!(target_os = "windows") && path.starts_with('/') { + PathBuf::from(format!("C:{path}")) + } else { + PathBuf::from(path) + } + } + fn supplied_identity( executable_path: &str, executable_digest: Option<&str>, @@ -300,13 +308,13 @@ mod tests { ) -> BinaryIdentity { BinaryIdentity { executable: ExecutableIdentity { - path: PathBuf::from(executable_path), + path: native_identity_path(executable_path), digest: executable_digest.map(digest), }, ancestors: ancestors .iter() .map(|(path, digest_value)| ExecutableIdentity { - path: PathBuf::from(path), + path: native_identity_path(path), digest: digest_value.map(digest), }) .collect(), @@ -450,7 +458,7 @@ mod tests { .hashes .lock() .unwrap() - .contains_key(Path::new("/sandbox/other")) + .contains_key(&native_identity_path("/sandbox/other")) ); } @@ -480,8 +488,8 @@ mod tests { assert!(error.contains("capacity")); let hashes = cache.hashes.lock().unwrap(); assert_eq!(hashes.len(), 4095); - assert!(!hashes.contains_key(Path::new("/sandbox/new-leaf"))); - assert!(!hashes.contains_key(Path::new("/sandbox/new-ancestor"))); + assert!(!hashes.contains_key(&native_identity_path("/sandbox/new-leaf"))); + assert!(!hashes.contains_key(&native_identity_path("/sandbox/new-ancestor"))); } #[test] diff --git a/crates/openshell-supervisor-network/src/lib.rs b/crates/openshell-supervisor-network/src/lib.rs index 458e236f01..3070472b95 100644 --- a/crates/openshell-supervisor-network/src/lib.rs +++ b/crates/openshell-supervisor-network/src/lib.rs @@ -8,8 +8,6 @@ //! owned by the orchestrator; this crate produces denials but does not //! aggregate them. -#[cfg(target_os = "windows")] -pub mod host; pub mod identity; pub mod identity_source; pub mod l7; diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index 74a14466da..e52ec21cde 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -1538,6 +1538,7 @@ fn validate_opa_static_settings(data: &mut serde_json::Value) -> Result<()> { fn redacted_policy_violation_category(violation: &PolicyViolation) -> &'static str { match violation { PolicyViolation::InvalidProcessIdentity { .. } => "invalid process identity", + PolicyViolation::InvalidUiClipboardAccess { .. } => "invalid UI clipboard access", PolicyViolation::InvalidLandlockCompatibility { .. } => "invalid Landlock compatibility", PolicyViolation::PathTraversal { .. } | PolicyViolation::RelativePath { .. } @@ -3343,6 +3344,7 @@ mod tests { run_as_user: "sandbox".to_string(), run_as_group: "sandbox".to_string(), }), + ui: None, network_policies, network_middlewares: std::collections::HashMap::default(), } @@ -5513,6 +5515,7 @@ process: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto_with_pid_and_binary_identity_required(&proto, 0, false) .expect("engine from relaxed proto"); @@ -6051,6 +6054,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -6122,6 +6126,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -6198,6 +6203,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -7941,6 +7947,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -7998,6 +8005,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -8056,6 +8064,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -8116,6 +8125,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -8175,6 +8185,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -9686,6 +9697,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); let input = NetworkInput { @@ -9756,6 +9768,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("Failed to create engine from proto"); @@ -9986,6 +9999,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).unwrap(); // Port 443 @@ -10951,6 +10965,7 @@ network_policies: process: None, network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let pid = std::process::id(); // accessible root, leaf paths absent @@ -11634,6 +11649,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; // Build engine with our PID (symlink resolution will work via /proc/self/root/) @@ -11797,6 +11813,7 @@ process: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; // Initial load at pid=0 — no symlink expansion diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index ec567bc47b..3638b321ac 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -273,6 +273,7 @@ impl ProxyHandle { network_mediation_source: Option>, policy_dns_store: Option>, direct_listener_identity: Option, + required_proxy_authorization: Option>, ) -> Result { // Use override bind_addr, fall back to policy http_addr, then default // to loopback:3128. The default allows the proxy to function when no @@ -289,6 +290,11 @@ impl ProxyHandle { } let source_backed = network_mediation_source.is_some(); + if source_backed && required_proxy_authorization.is_some() { + return Err(miette::miette!( + "proxy authorization cannot be required for a network mediation source" + )); + } let listener = if source_backed { None } else { @@ -511,6 +517,7 @@ impl ProxyHandle { let dtx = denial_tx.clone(); let atx = activity_tx.clone(); let endpoint_observations = endpoint_observation_tx.clone(); + let required_authorization = required_proxy_authorization.clone(); tokio::spawn(async move { #[allow(clippy::large_futures)] if let Err(err) = handle_mediated_connection( @@ -534,6 +541,7 @@ impl ProxyHandle { dtx, atx, endpoint_observations, + required_authorization, ) .await { @@ -1526,7 +1534,6 @@ fn build_accept_error_event( .message(message) .build() } - fn classify_accept_error( err: &std::io::Error, consecutive_resource_errors: &mut u32, @@ -1534,7 +1541,6 @@ fn classify_accept_error( ) -> AcceptAction { #[cfg(not(unix))] let _ = (err, &mut *consecutive_resource_errors); - #[cfg(unix)] if matches!( err.raw_os_error(), @@ -2286,6 +2292,40 @@ where .await } +fn constant_time_bytes_eq(left: &[u8], right: &[u8]) -> bool { + let max_len = left.len().max(right.len()); + let mut difference = left.len() ^ right.len(); + for index in 0..max_len { + let left_byte = left.get(index).copied().unwrap_or_default(); + let right_byte = right.get(index).copied().unwrap_or_default(); + difference |= usize::from(left_byte ^ right_byte); + } + difference == 0 +} + +fn has_valid_proxy_authorization(request: &str, expected: &str) -> bool { + let mut provided = None; + for line in request.split("\r\n").skip(1) { + if line.is_empty() { + break; + } + let Some((name, value)) = line.split_once(':') else { + return false; + }; + if name.eq_ignore_ascii_case("proxy-authorization") { + // Reject duplicates even when both values are correct. Accepting + // ambiguous credentials can produce parser differentials between + // this proxy and downstream HTTP implementations. + if provided.is_some() { + return false; + } + provided = Some(value.trim()); + } + } + + provided.is_some_and(|value| constant_time_bytes_eq(value.as_bytes(), expected.as_bytes())) +} + // Many distinct, non-related context parameters are required for a CONNECT // dispatch; bundling them into a struct would just shift the noise into call // sites. @@ -2338,6 +2378,7 @@ async fn handle_tcp_connection( denial_tx, activity_tx, endpoint_observation_tx, + None, )) .await } @@ -2409,6 +2450,7 @@ async fn handle_mediated_connection( denial_tx: Option>, activity_tx: Option, endpoint_observation_tx: Option, + required_proxy_authorization: Option>, ) -> Result<()> { // Bind observations to the policy/provider inventory active when this // connection was accepted, even if configuration changes while it runs. @@ -2492,6 +2534,20 @@ async fn handle_mediated_connection( respond(&mut client, b"HTTP/1.1 400 Bad Request\r\n\r\n").await?; return Ok(()); } + if let Some(expected) = required_proxy_authorization.as_deref() + && !has_valid_proxy_authorization(request, expected) + { + warn!("Rejected host proxy request with missing or invalid per-sandbox credentials"); + respond( + &mut client, + b"HTTP/1.1 407 Proxy Authentication Required\r\n\ + Proxy-Authenticate: Basic realm=\"OpenShell\"\r\n\ + Content-Length: 0\r\n\ + Connection: close\r\n\r\n", + ) + .await?; + return Ok(()); + } let mut lines = request.split("\r\n"); let request_line = lines.next().unwrap_or(""); let mut parts = request_line.split_whitespace(); @@ -6849,17 +6905,60 @@ fn is_benign_relay_error(err: &miette::Report) -> bool { reason = "Test code: test fixtures and explicit control-flow markers are idiomatic in tests." )] mod tests { + #[test] + fn proxy_authorization_rejects_missing_wrong_cross_and_duplicate_credentials() { + let expected = "Basic sandbox-a-generation"; + let request = |headers: &str| format!("CONNECT example.com:443 HTTP/1.1\r\n{headers}\r\n"); + for headers in [ + "", + "Proxy-Authorization: Basic wrong\r\n", + "Proxy-Authorization: Basic sandbox-b-generation\r\n", + "Proxy-Authorization: Basic sandbox-a-generation\r\nProxy-Authorization: Basic sandbox-a-generation\r\n", + "Malformed-header\r\nProxy-Authorization: Basic sandbox-a-generation\r\n", + ] { + assert!(!has_valid_proxy_authorization(&request(headers), expected)); + } + assert!(has_valid_proxy_authorization( + &request("proxy-authorization: Basic sandbox-a-generation\r\n"), + expected, + )); + } + use super::*; use openshell_core::proposals::AgentProposals; use std::collections::HashMap as TestHashMap; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + use std::path::Path; use std::sync::Arc; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; + // These contract tests use host-native absolute identities and matching + // policy declarations. Relative-path rejection remains independently tested. + fn native_identity_path(path: impl AsRef) -> PathBuf { + let path = path.as_ref(); + if cfg!(target_os = "windows") && path.to_string_lossy().starts_with('/') { + PathBuf::from(format!("C:{}", path.display())) + } else { + path.to_path_buf() + } + } + + fn native_identity_policy(source: &str) -> std::borrow::Cow<'_, str> { + if cfg!(target_os = "windows") { + std::borrow::Cow::Owned(source.replace("path: /", "path: C:/")) + } else { + std::borrow::Cow::Borrowed(source) + } + } + + fn native_identity_engine(rego: &str, source: &str) -> Result { + OpaEngine::from_strings(rego, &native_identity_policy(source)) + } + #[test] fn supplied_identity_preserves_authorized_endpoint_metadata() { - let engine = OpaEngine::from_strings( + let engine = native_identity_engine( include_str!("../data/sandbox-policy.rego"), r#" network_policies: @@ -6890,7 +6989,7 @@ process: .expect("load policy"); let identity = Ok(ContractBinaryIdentity { executable: ContractExecutableIdentity { - path: PathBuf::from("/usr/bin/python3"), + path: native_identity_path("/usr/bin/python3"), digest: Some("00".repeat(32).parse().expect("digest")), }, ancestors: Vec::new(), @@ -6916,7 +7015,7 @@ process: #[test] fn supplied_identity_rejects_same_path_replacement() { - let engine = OpaEngine::from_strings( + let engine = native_identity_engine( include_str!("../data/sandbox-policy.rego"), r#" network_policies: @@ -6946,7 +7045,7 @@ process: let identity = |digest_byte: &str| { Ok(ContractBinaryIdentity { executable: ContractExecutableIdentity { - path: PathBuf::from("/sandbox/bin/client"), + path: native_identity_path("/sandbox/bin/client"), digest: Some(digest_byte.repeat(32).parse().expect("digest")), }, ancestors: Vec::new(), @@ -6973,7 +7072,7 @@ process: #[test] fn supplied_identity_rejects_replaced_authorizing_ancestor() { - let engine = OpaEngine::from_strings( + let engine = native_identity_engine( include_str!("../data/sandbox-policy.rego"), r#" network_policies: @@ -6999,11 +7098,11 @@ process: let identity = |ancestor_digest: &str| { Ok(ContractBinaryIdentity { executable: ContractExecutableIdentity { - path: PathBuf::from("/sandbox/bin/client"), + path: native_identity_path("/sandbox/bin/client"), digest: Some("11".repeat(32).parse().expect("digest")), }, ancestors: vec![ContractExecutableIdentity { - path: PathBuf::from("/sandbox/bin/launcher"), + path: native_identity_path("/sandbox/bin/launcher"), digest: Some(ancestor_digest.repeat(32).parse().expect("digest")), }], cmdline_paths: Vec::new(), @@ -7046,11 +7145,11 @@ process: run_as_group: sandbox "#; let rego = include_str!("../data/sandbox-policy.rego"); - let engine = OpaEngine::from_strings(rego, POLICY_DATA).expect("load policy"); + let engine = native_identity_engine(rego, POLICY_DATA).expect("load policy"); let identity = |digest_byte: &str| { Ok(ContractBinaryIdentity { executable: ContractExecutableIdentity { - path: PathBuf::from("/sandbox/bin/client"), + path: native_identity_path("/sandbox/bin/client"), digest: Some(digest_byte.repeat(32).parse().expect("digest")), }, ancestors: Vec::new(), @@ -7064,7 +7163,9 @@ process: authorize_supplied_identity(&engine, &identity_cache, intent(), &identity("11")); assert!(matches!(original.action, NetworkAction::Allow { .. })); - engine.reload(rego, POLICY_DATA).expect("reload policy"); + engine + .reload(rego, &native_identity_policy(POLICY_DATA)) + .expect("reload policy"); let replacement = authorize_supplied_identity(&engine, &identity_cache, intent(), &identity("22")); @@ -7078,7 +7179,7 @@ process: #[test] fn supplied_identity_rejects_missing_ancestor_digest_before_policy() { - let engine = OpaEngine::from_strings( + let engine = native_identity_engine( include_str!("../data/sandbox-policy.rego"), r#" network_policies: @@ -7103,11 +7204,11 @@ process: .expect("load policy"); let identity = Ok(ContractBinaryIdentity { executable: ContractExecutableIdentity { - path: PathBuf::from("/sandbox/bin/client"), + path: native_identity_path("/sandbox/bin/client"), digest: Some("11".repeat(32).parse().expect("digest")), }, ancestors: vec![ContractExecutableIdentity { - path: PathBuf::from("/sandbox/bin/launcher"), + path: native_identity_path("/sandbox/bin/launcher"), digest: None, }], cmdline_paths: Vec::new(), @@ -7129,7 +7230,7 @@ process: #[tokio::test] async fn staged_transparent_open_waits_for_l4_policy() { - let engine = OpaEngine::from_strings( + let engine = native_identity_engine( include_str!("../data/sandbox-policy.rego"), r#" network_policies: @@ -7159,7 +7260,7 @@ process: let identity = || { Ok(ContractBinaryIdentity { executable: ContractExecutableIdentity { - path: PathBuf::from("/usr/bin/curl"), + path: native_identity_path("/usr/bin/curl"), digest: Some("00".repeat(32).parse().unwrap()), }, ancestors: Vec::new(), @@ -7252,7 +7353,10 @@ process: .expect("policy denial is sent to mapper"); assert_eq!(event.host, "203.0.113.8"); assert_eq!(event.port, 443); - assert_eq!(event.binary, "/usr/bin/curl"); + assert_eq!( + event.binary, + native_identity_path("/usr/bin/curl").display().to_string() + ); assert_eq!(event.denial_stage, "transparent_tcp_connect"); assert!(denial_rx.try_recv().is_err(), "exactly one mapper event"); } @@ -7354,7 +7458,7 @@ process: { run_as_user: sandbox, run_as_group: sandbox } stream: Box::new(stream), binary_identity: Ok(ContractBinaryIdentity { executable: ContractExecutableIdentity { - path: PathBuf::from("/usr/bin/curl"), + path: native_identity_path("/usr/bin/curl"), digest: Some("00".repeat(32).parse().unwrap()), }, ancestors: Vec::new(), @@ -7376,7 +7480,7 @@ process: { run_as_user: sandbox, run_as_group: sandbox } #[tokio::test] async fn staged_transparent_open_dials_only_pinned_policy_dns_addresses() { - let engine = OpaEngine::from_strings( + let engine = native_identity_engine( include_str!("../data/sandbox-policy.rego"), POLICY_DNS_OPEN_POLICY, ) @@ -7416,7 +7520,7 @@ process: { run_as_user: sandbox, run_as_group: sandbox } #[tokio::test] async fn staged_transparent_open_proposes_a_denied_observation_hostname() { - let engine = OpaEngine::from_strings( + let engine = native_identity_engine( include_str!("../data/sandbox-policy.rego"), POLICY_DNS_OPEN_POLICY, ) @@ -7451,12 +7555,15 @@ process: { run_as_user: sandbox, run_as_group: sandbox } let event = denial_rx.try_recv().expect("denial is sent to the mapper"); assert_eq!(event.host, "unknown.example"); assert_eq!(event.port, 443); - assert_eq!(event.binary, "/usr/bin/curl"); + assert_eq!( + event.binary, + native_identity_path("/usr/bin/curl").display().to_string() + ); } #[tokio::test] async fn staged_transparent_open_never_relays_an_observation_that_policy_allows() { - let engine = OpaEngine::from_strings( + let engine = native_identity_engine( include_str!("../data/sandbox-policy.rego"), POLICY_DNS_OPEN_POLICY, ) @@ -7522,7 +7629,7 @@ process: { run_as_user: sandbox, run_as_group: sandbox } #[test] fn pinned_plan_requires_the_mapping_of_the_deciding_generation() { - let engine = OpaEngine::from_strings( + let engine = native_identity_engine( include_str!("../data/sandbox-policy.rego"), POLICY_DNS_OPEN_POLICY, ) @@ -7567,7 +7674,7 @@ process: { run_as_user: sandbox, run_as_group: sandbox } engine .reload( include_str!("../data/sandbox-policy.rego"), - POLICY_DNS_OPEN_POLICY, + &native_identity_policy(POLICY_DNS_OPEN_POLICY), ) .unwrap(); let reloaded = decide("db.example", 5432); @@ -7590,7 +7697,10 @@ process: { run_as_user: sandbox, run_as_group: sandbox } ); assert_eq!( mapped.format_shorthand(), - "NET:OPEN [MED] DENIED /usr/bin/curl(0) -> blocked.invalid:80 [reason:transparent_tcp_policy_denied]" + format!( + "NET:OPEN [MED] DENIED {}(0) -> blocked.invalid:80 [reason:transparent_tcp_policy_denied]", + native_identity_path("/usr/bin/curl").display() + ) ); assert_eq!( serde_json::to_value(mapped).unwrap()["dst_endpoint"], @@ -7613,7 +7723,7 @@ process: { run_as_user: sandbox, run_as_group: sandbox } #[tokio::test] async fn staged_policy_local_open_reaches_the_sandbox_scoped_api() { let engine = Arc::new( - OpaEngine::from_strings( + native_identity_engine( include_str!("../data/sandbox-policy.rego"), "network_policies: {}\n", ) @@ -7622,7 +7732,7 @@ process: { run_as_user: sandbox, run_as_group: sandbox } let cache = Arc::new(BinaryIdentityCache::new()); let identity = ContractBinaryIdentity { executable: ContractExecutableIdentity { - path: PathBuf::from("/usr/bin/bash"), + path: native_identity_path("/usr/bin/bash"), digest: Some("44".repeat(32).parse().unwrap()), }, ancestors: Vec::new(), @@ -7682,6 +7792,7 @@ process: { run_as_user: sandbox, run_as_group: sandbox } None, None, None, + None, )); workload .write_all(b"GET /v1/policy/current HTTP/1.1\r\nHost: policy.local\r\nConnection: close\r\n\r\n") @@ -7732,7 +7843,7 @@ process: { run_as_user: sandbox, run_as_group: sandbox } .unwrap(); }); let engine = Arc::new( - OpaEngine::from_strings( + native_identity_engine( include_str!("../data/sandbox-policy.rego"), &format!( r#" @@ -7758,7 +7869,7 @@ network_policies: stream: Box::new(stream), binary_identity: Ok(ContractBinaryIdentity { executable: ContractExecutableIdentity { - path: PathBuf::from("/usr/bin/curl"), + path: native_identity_path("/usr/bin/curl"), digest: Some("44".repeat(32).parse().unwrap()), }, ancestors: Vec::new(), @@ -7810,6 +7921,7 @@ network_policies: None, None, None, + None, )); let request = tokio::time::timeout(std::time::Duration::from_secs(5), request_rx) .await @@ -7936,7 +8048,7 @@ process: #[tokio::test] async fn staged_transparent_open_reports_identity_cache_capacity_exhaustion() { - let engine = OpaEngine::from_strings( + let engine = native_identity_engine( include_str!("../data/sandbox-policy.rego"), r#" network_policies: @@ -7964,7 +8076,7 @@ process: identity_cache .verify_or_cache_supplied_identity(&ContractBinaryIdentity { executable: ContractExecutableIdentity { - path: PathBuf::from(format!("/sandbox/pinned-{index}")), + path: native_identity_path(format!("/sandbox/pinned-{index}")), digest: Some("11".repeat(32).parse().unwrap()), }, ancestors: Vec::new(), @@ -7978,7 +8090,7 @@ process: stream: Box::new(stream), binary_identity: Ok(ContractBinaryIdentity { executable: ContractExecutableIdentity { - path: PathBuf::from("/sandbox/overflow"), + path: native_identity_path("/sandbox/overflow"), digest: Some("22".repeat(32).parse().unwrap()), }, ancestors: Vec::new(), @@ -8416,6 +8528,7 @@ network_policies: {} Some(Arc::new(FailedMediationSource)), None, None, + None, ) .await .expect("proxy starts before source accept"); diff --git a/crates/openshell-supervisor-network/src/run.rs b/crates/openshell-supervisor-network/src/run.rs index 1eba8a0742..1131ee1bda 100644 --- a/crates/openshell-supervisor-network/src/run.rs +++ b/crates/openshell-supervisor-network/src/run.rs @@ -201,6 +201,7 @@ pub async fn run_networking( host_gateway_ip: Option, #[cfg(target_os = "linux")] transparent_runtime: Option, network_mediation_source: Option>, + direct_proxy: Option, ) -> Result { // Build the policy-local route context. The orchestrator's policy poll // loop also holds an `Arc` clone (via `Networking::policy_local_ctx`) so @@ -467,10 +468,15 @@ pub async fn run_networking( // originating inside the namespace can reach the proxy. Otherwise the // proxy falls back to the policy-declared http_addr (loopback in // tests, etc.). - let bind_addr = proxy_bind_ip.map(|ip| { - let port = proxy_policy.http_addr.map_or(3128, |addr| addr.port()); - SocketAddr::new(ip, port) - }); + let bind_addr = direct_proxy + .as_ref() + .map(|proxy| proxy.bind_addr) + .or_else(|| { + proxy_bind_ip.map(|ip| { + let port = proxy_policy.http_addr.map_or(3128, |addr| addr.port()); + SocketAddr::new(ip, port) + }) + }); let proxy_handle = ProxyHandle::start_with_bind_addr( proxy_policy, @@ -491,7 +497,12 @@ pub async fn run_networking( mediated_policy_dns .as_ref() .map(|runtime| runtime.store.clone()), - None, + direct_proxy + .as_ref() + .map(|proxy| proxy.binary_identity.clone()), + direct_proxy + .as_ref() + .map(|proxy| Arc::::from(proxy.authorization.clone())), ) .await?; Some(proxy_handle) diff --git a/crates/openshell-supervisor/Cargo.toml b/crates/openshell-supervisor/Cargo.toml index 483dd50bff..11cfc03fcc 100644 --- a/crates/openshell-supervisor/Cargo.toml +++ b/crates/openshell-supervisor/Cargo.toml @@ -51,6 +51,9 @@ defaults-without-telemetry = ["bundled-ca-roots"] telemetry = ["openshell-core/telemetry"] bundled-ca-roots = ["openshell-supervisor-network/bundled-ca-roots"] +[target.'cfg(target_os = "windows")'.dependencies] +openshell-mxc-boundary = { path = "../openshell-mxc-boundary" } + [dev-dependencies] tokio = { workspace = true, features = ["test-util"] } futures = { workspace = true } diff --git a/crates/openshell-supervisor/src/isolation_backends.rs b/crates/openshell-supervisor/src/isolation_backends.rs new file mode 100644 index 0000000000..7538066396 --- /dev/null +++ b/crates/openshell-supervisor/src/isolation_backends.rs @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Binary composition of platform isolation-backend implementations. + +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use openshell_isolation_interface::contract::IsolationBackend; + +pub fn sandbox_backend( + ca_file_paths: Arc>>, + provider_credentials: openshell_core::provider_credentials::ProviderCredentialState, + sandbox_bearer: openshell_core::jwt::SessionBearerTokenSlot, +) -> Arc { + #[cfg(not(target_os = "windows"))] + let backend = openshell_sandbox_backend::OpenShellRuntimeBackend::new( + ca_file_paths, + provider_credentials, + sandbox_bearer, + ); + #[cfg(target_os = "windows")] + let backend = openshell_mxc_boundary::backend::MxcRuntimeBackend::new( + ca_file_paths, + provider_credentials, + sandbox_bearer, + ); + Arc::new(backend) +} diff --git a/crates/openshell-supervisor/src/lib.rs b/crates/openshell-supervisor/src/lib.rs index 869ef888cb..1b78cf240f 100644 --- a/crates/openshell-supervisor/src/lib.rs +++ b/crates/openshell-supervisor/src/lib.rs @@ -18,6 +18,7 @@ compile_error!( mod activity_aggregator; mod denial_aggregator; mod endpoint_status; +mod isolation_backends; mod mechanistic_mapper; mod provider_readiness; @@ -599,6 +600,7 @@ pub async fn run_network_proxy( #[cfg(target_os = "linux")] None, None, + None, ) .await?; @@ -850,11 +852,11 @@ pub async fn run_sandbox( let session_id = runtime_descriptor.session_id; let ca_file_paths = Arc::new(std::sync::Mutex::new(None)); let backend: Arc = - Arc::new(openshell_sandbox_backend::OpenShellRuntimeBackend::new( + isolation_backends::sandbox_backend( ca_file_paths.clone(), provider_credentials.clone(), sandbox_bearer, - )); + ); let mut registry = openshell_isolation_interface::contract::BackendRegistry::new(); registry .register(backend) @@ -945,7 +947,10 @@ pub async fn run_sandbox( // API read the current value so proposals target the correct workspace. let (workspace_tx, workspace_rx) = tokio::sync::watch::channel(String::new()); - let remote_network_source = remote_boundary.0.network_mediation_source(); + let direct_proxy = remote_boundary.0.direct_proxy_configuration(); + let remote_network_source = direct_proxy + .is_none() + .then(|| remote_boundary.0.network_mediation_source()); let remote_host_gateway_ip = remote_boundary.0.host_gateway_ip(); let (remote_ready, backend_name, ca_file_paths) = { let (bound, backend_name, ca_file_paths) = remote_boundary; @@ -983,7 +988,8 @@ pub async fn run_sandbox( remote_host_gateway_ip, #[cfg(target_os = "linux")] None, - Some(remote_network_source), + remote_network_source, + direct_proxy, ) .await?, ); diff --git a/docs/how-it-works/gateways/configuration.mdx b/docs/how-it-works/gateways/configuration.mdx index 3b70608661..11fc13baeb 100644 --- a/docs/how-it-works/gateways/configuration.mdx +++ b/docs/how-it-works/gateways/configuration.mdx @@ -849,7 +849,7 @@ The gateway verifies supervisor JWT-SVIDs with JWT bundles fetched from the SPIFFE Workload API, so this validation path does not require gateway access to the SPIRE OIDC discovery endpoint or its TLS CA. -### MXC +### MXC audit configuration The MXC driver runs Windows workloads through `wxc-exec`. Enable ETW auditing to map Windows Sandboxing provider events into the gateway's OCSF stream. @@ -864,13 +864,14 @@ log_level = "info" compute_driver = "mxc" [openshell.drivers.mxc] -wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe" -backend = "process_container" -default_configuration_id = "composable" -pc_least_privilege = false -pc_capabilities = [] -debug = false -etw_audit = true +wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe" +supervisor_binary_path = "C:\\OpenShell\\openshell-supervisor.exe" +sandbox_binary_path = "C:\\OpenShell\\openshell-windows-sandbox.exe" +backend = "process_container" +pc_least_privilege = false +pc_capabilities = [] +debug = false +etw_audit = true ``` `etw_audit` defaults to `false`. When enabled, the gateway account must be an @@ -878,6 +879,12 @@ administrator or belong to the Windows Performance Log Users group. Workload commands and working directories remain sandbox-scoped and must be supplied in the `mxc` driver configuration when creating a sandbox. +`OPENSHELL_OCSF_JSON=1` enables the gateway's JSONL audit +sink independently of the selected compute driver. Set `OPENSHELL_OCSF_LOG_DIR` +to override the default `%PROGRAMDATA%\OpenShell\logs` directory on Windows +or `$XDG_STATE_HOME/openshell/logs` on Unix. Prefer the gateway's configured +`ocsf_log` output for retention controls and loss metrics. + The driver records executable identity in process audit events and omits raw command arguments because they can contain credentials or personal data. See [OCSF JSON Export](/observability/ocsf-json-export) for durable Windows audit @@ -1119,6 +1126,54 @@ OpenShell sends no override and Podman applies its runtime-selected profile. The setting applies to the workload container; the supervisor retains Podman's runtime-selected profile. +### MXC + +The MXC driver is Windows-only and opt-in. It links into the gateway and invokes Microsoft MXC through `wxc-exec.exe`. The driver starts `openshell-supervisor --role=isolation-backend` on the host and `openshell-windows-sandbox.exe` inside each ProcessContainer. The standard authenticated Sandbox Protocol and supervisor session provide lifecycle, exec, forwarding, provider refresh, and network policy. + +The dedicated `openshell-windows-sandbox.exe` uses a Windows boundary library for process operations and forwarding. It replaces the generic sandbox binary in the Windows runtime pair; the supervisor remains a separate host process, not part of the compute driver. No separate supervisor-relay executable is required. Set `wxc_exec_path` to an absolute path; configuration preflight rejects an omitted or relative path. Existing MXC configurations that explicitly select `openshell-sandbox.exe` must update `sandbox_binary_path` to the Windows executable. + +```toml +[openshell] +version = 2 + +[openshell.gateway] +bind_address = "127.0.0.1:17670" +log_level = "info" +compute_driver = "mxc" +# Required when gateway TLS is enabled. The gateway injects this bundle into +# the host supervisor. +guest_tls_ca = "C:\\OpenShell\\certs\\ca.pem" +guest_tls_cert = "C:\\OpenShell\\certs\\client.pem" +guest_tls_key = "C:\\OpenShell\\certs\\client-key.pem" + +[openshell.drivers.mxc] +wxc_exec_path = "C:\\mxc\\wxc-exec.exe" +supervisor_binary_path = "C:\\OpenShell\\openshell-supervisor.exe" +sandbox_binary_path = "C:\\OpenShell\\openshell-windows-sandbox.exe" +# Defaults to %LOCALAPPDATA%\OpenShell\mxc. +state_dir = "C:\\Users\\operator\\AppData\\Local\\OpenShell\\mxc" +# Empty derives the gateway loopback URL and its TLS mode. +grpc_endpoint = "" +# The RFC 0012 MXC path requires process_container. +backend = "process_container" +pc_least_privilege = false +pc_capabilities = [] +pc_allow_local_network = true +pc_minimal_env = false +debug = false +etw_audit = false +``` + +The packaged supervisor and sandbox binaries default to siblings of `openshell-gateway.exe`; explicit paths are useful for development layouts. The driver protects host supervisor tokens and descriptors with an owner-only Windows DACL. + +Existing ProcessContainer configurations may retain `default_configuration_id`. This legacy IsolationSession-only field is accepted for configuration compatibility but has no effect on ProcessContainer. It does not enable the unsupported `isolation_session` backend; omit it in new configurations. + +Supply the workload command and working directory through `sandbox create --driver-config-json`, for example `{"mxc":{"command":["C:\\Windows\\System32\\cmd.exe","/d","/c","echo hello"],"cwd":"C:\\work"}}`. Both are required. Supply workload environment through `sandbox create --env` or `--env-from`; it is not part of gateway configuration. + +The driver assigns distinct Sandbox Protocol and proxy listeners plus fresh credentials to every generation. The host proxy rejects missing, invalid, duplicate, or cross-sandbox proxy credentials before forwarding. MXC denies direct Internet egress and permits only the `127.0.0.1/32` route required by the authenticated transport and proxy. That loopback exception does not isolate unrelated host services. The current explicit-proxy path attributes descendant traffic to the admitted main workload binary rather than resolving each Windows socket owner. Treat the gateway host as trusted and avoid policies that rely on different network rights for child executables. + +Attached provider credentials use the ordinary live supervisor refresh path. Static values stay in the host supervisor's endpoint-bound resolver and are injected only for matching requests. Dynamic token grants remain request-time operations in the host proxy. + ### MicroVM Each sandbox runs inside its own libkrun microVM managed by the standalone `openshell-driver-vm` subprocess. Use this driver when you want stronger isolation than container namespaces alone. diff --git a/docs/how-it-works/sandboxes/overview.mdx b/docs/how-it-works/sandboxes/overview.mdx index a9a1866d53..d37fa63a8d 100644 --- a/docs/how-it-works/sandboxes/overview.mdx +++ b/docs/how-it-works/sandboxes/overview.mdx @@ -258,6 +258,11 @@ openshell sandbox create --from registry.example.com/agents/my-agent:1.0 Refer to [Default Policy](/how-it-works/policies/default-policy), [Run Your First Agent](/about/run-your-first-agent), and the [bring-your-own-container example](https://github.com/NVIDIA/OpenShell/tree/main/examples/bring-your-own-container). +Reusable image and resource settings belong in sandbox templates; see +[Templates](/how-it-works/sandboxes/templates) for the current SDK workflow. +The pre-0.1.0 `base` image alias and CLI template commands do not apply to +this release; pass an explicit image reference with `--from`. + ## Connect to a Sandbox Attach to the canonical main process in a running sandbox: @@ -383,6 +388,16 @@ openshell sandbox create --env API_KEY=sk-test --env DEBUG=1 -- my-agent Variables set with `--env` are available to all processes in the sandbox, including the initial command, interactive shells, and exec commands. +Use `--env-from KEY[=ENVVAR]` when the value should come from the CLI process environment instead of appearing in the CLI process arguments. If `ENVVAR` is omitted, OpenShell reads `KEY`: + +```shell +export SESSION_TOKEN="..." +openshell sandbox create --env-from SESSION_TOKEN -- my-agent +openshell sandbox create --env-from AGENT_TOKEN=SESSION_TOKEN -- my-agent +``` + +`--env-from` changes only how the CLI receives the value. The resulting variable is still available to processes in the sandbox, just like `--env`. + When an `--env` key looks like a credential — a known provider variable, or a name whose underscore-separated segments include a credential word such as `TOKEN`, `SECRET`, `PASSWORD`, `CREDENTIAL`, `API_KEY`, `ACCESS_KEY`, or `SECRET_KEY` (for example `DB_TOKEN` or `MY_ACCESS_KEY`) — `sandbox create` prints a non-blocking warning. Matching is on whole segments, so unrelated names like `TOKENIZERS_PARALLELISM` or `PASSWORDLESS_LOGIN` do not warn. The agent inside the sandbox can read plain environment values directly, so to hide a secret from the agent, attach it through a [profile-backed provider](/how-it-works/providers/profiles) with `--provider` instead. Suppress the warning with `--no-credential-warnings`. Detection uses the key name only; values are never inspected or printed. You can also set per-command environment variables with `sandbox exec`: @@ -909,6 +924,20 @@ When a multi-sandbox delete fails for one entry, the CLI reports that sandbox's failure and continues with the remaining names. The command exits with an error after it attempts every requested deletion if any entry failed. +Automation that already observed a sandbox's immutable identity can prevent a +same-name replacement from being deleted: + +```shell +openshell sandbox delete my-sandbox \ + --expected-id \ + --expected-resource-version +``` + +Identity preconditions require exactly one sandbox name, and a resource version +may be supplied only with the immutable ID. The gateway returns an `ABORTED` +error without changing the sandbox or calling its compute driver when either +value no longer matches. + ## Sandbox Lifecycle Every sandbox moves through a defined set of phases: diff --git a/docs/observability/ocsf-json-export.mdx b/docs/observability/ocsf-json-export.mdx index 9a5b970168..adcbe256dd 100644 --- a/docs/observability/ocsf-json-export.mdx +++ b/docs/observability/ocsf-json-export.mdx @@ -63,9 +63,13 @@ $env:OPENSHELL_OCSF_LOG_DIR = "D:\OpenShell\audit" openshell-gateway --drivers mxc --config gateway.toml ``` -The sink is available only when Windows selects the MXC driver. It rotates +This environment-configured sink is available on all platforms independently of +the selected compute driver. On Unix hosts its default directory is +`$XDG_STATE_HOME/openshell/logs` (or `$HOME/.local/state/openshell/logs`). It rotates daily and retains the three most recent files. Diagnostic `--log-level` values do not suppress records in this explicitly enabled audit sink. +Prefer the `gateway.toml` output above for configured retention and loss metrics; +enabling both sinks writes to both independently. ## Output Location diff --git a/proto/compute_driver.proto b/proto/compute_driver.proto index f278174b5c..c00c549f6f 100644 --- a/proto/compute_driver.proto +++ b/proto/compute_driver.proto @@ -106,6 +106,10 @@ message GetCapabilitiesResponse { // Gateways require an exact policy match before activating the driver. // Empty denotes a legacy driver and requires explicit admission opt-out. string resource_admission_policy = 13; + // Whether this configured driver instance completely enforces the current + // portable SandboxPolicy.ui contract. Partial support must report false so + // the gateway rejects every explicit UI section before provisioning. + bool supports_ui_policy = 14; } message AuthenticateSandboxRequest { diff --git a/proto/openshell.proto b/proto/openshell.proto index 83ffbe75ad..ca7290152a 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -964,6 +964,10 @@ message ComputeDriverCapabilities { // Static portable resource request forms reported by the driver. ResourceCapabilities resource_capabilities = 3; + + // Whether the configured driver instance completely enforces the portable + // SandboxPolicy.ui contract. + bool supports_ui_policy = 4; } // Static portable resource request forms reported by a compute driver. diff --git a/proto/sandbox.proto b/proto/sandbox.proto index 1d837b7f0e..fb33d4cb09 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -33,6 +33,10 @@ message SandboxPolicy { // policy-local names. At most 10 configs are accepted, and at most 10 stages // can be selected per request. map network_middlewares = 6; + // Static, platform-neutral user-interface access policy. Within an explicit + // section, omitted capabilities deny. Omitting the section preserves the + // compute platform's existing behavior. + UiPolicy ui = 7; } // Filesystem access policy. @@ -59,6 +63,32 @@ message ProcessPolicy { string run_as_group = 2; } +// Directional clipboard access for a sandboxed workload. +enum UiClipboardAccess { + // Unspecified resolves to no clipboard access. + UI_CLIPBOARD_ACCESS_UNSPECIFIED = 0; + // No clipboard reads or writes. + UI_CLIPBOARD_ACCESS_NONE = 1; + // The sandbox may read host clipboard contents. + UI_CLIPBOARD_ACCESS_READ = 2; + // The sandbox may write host clipboard contents. + UI_CLIPBOARD_ACCESS_WRITE = 3; + // The sandbox may read and write host clipboard contents. + UI_CLIPBOARD_ACCESS_ALL = 4; +} + +// Platform-neutral user-interface capabilities. Every omitted field in an +// explicit policy defaults to deny. Compute platforms without complete support +// reject the entire explicit policy before provisioning. +message UiPolicy { + // Allow the sandbox to display graphical windows. + bool allow_graphical_ui = 1; + // Directional host clipboard access. + UiClipboardAccess clipboard = 2; + // Allow the sandbox to synthesize keyboard or pointer input. + bool allow_input_injection = 3; +} + // A named network access policy rule. message NetworkPolicyRule { // Human-readable name for this policy rule. diff --git a/rfc/0013-native-windows-mxc/README.md b/rfc/0013-native-windows-mxc/README.md index 955bb1e9dc..bbbee1b412 100644 --- a/rfc/0013-native-windows-mxc/README.md +++ b/rfc/0013-native-windows-mxc/README.md @@ -5,461 +5,197 @@ state: review links: - https://github.com/NVIDIA/OpenShell/issues/2050 - https://github.com/NVIDIA/OpenShell/pull/2071 + - https://github.com/NVIDIA/OpenShell/pull/3370 --- # RFC 0013 - Native Windows Support via the MXC Compute Driver - - ## Summary -This RFC proposes extending OpenShell to run natively on Windows 11 (x64 and -ARM64) without a Linux VM, Docker Desktop, or WSL. It will produce a new -compute driver, `openshell-driver-mxc`, that will use Microsoft -Execution Containers (MXC, via `wxc-exec.exe`) as the sandbox primitive. - -The central architectural conclusion is that OpenShell rejects porting its Linux -in-sandbox supervisor to Windows as part of this design. The value layers the -supervisor delivers on Linux — egress policy enforcement (OPA), L7 HTTP -inspection — are relocated to the host by running OpenShell's existing CONNECT -proxy inside the driver process and pointing MXC's built-in `network.proxy` -redirect at it. The integration therefore collapses to three moving parts: the -native Windows OpenShell gateway, a Windows-only MXC compute driver crate, and an -unmodified `wxc-exec` binary, with no OpenShell binary running inside the -sandbox. +OpenShell runs natively on Windows 11 by using Microsoft Execution Containers +(MXC, through `wxc-exec.exe`) as an RFC 0012 isolation backend. The gateway's +in-process MXC compute driver provisions a host +`openshell-supervisor --role=isolation-backend` and an `openshell-windows-sandbox` +boundary inside each ProcessContainer. + +The authenticated Sandbox Protocol is the only runtime control and forwarding +transport. MXC supplies the Windows outer fence; the existing supervisor owns +policy evaluation, credentials, network proxying, and the gateway session. ## Motivation -OpenShell sandboxes autonomous AI agents. On Linux it does so through the -Docker, Podman, Kubernetes, and libkrun-VM compute drivers, each pairing a -compute backend with an in-sandbox `openshell-sandbox` supervisor that enforces -policy and runs the agent. None of those drivers give a first-class experience on -Windows: Docker Desktop uses a WSL2-backed VM for Linux containers and adds -licensing and resource overhead, WSL2 adds install and networking complexity, and -Hyper-V/Windows Sandbox are heavy and require elevation. Today a Windows -developer or enterprise host cannot run an OpenShell sandbox without standing up -a Linux runtime underneath it. - -Windows is a primary environment for the agents OpenShell targets, particularly -for GeForce and enterprise Windows users. We want native, OS-level isolation -that runs unelevated, with the same policy, inference, and audit guarantees users -get on Linux. Windows 11-Preview Builds now supports MXC (`processcontainer`, backed by -AppContainer + a Low Integrity token) as an OS-native sandbox primitive that -already honors a `network.proxy` egress redirect. That makes a supervisor-free, -host-enforced design feasible without any changes to Microsoft's runtime. - -This is worth an RFC rather than a single issue because it is a cross-cutting -architectural decision: it adds a new compute-driver model (in-process, -supervisor-free), a new platform target with its own build/CI lane, a new -policy-translation seam between OpenShell policy and MXC config, and a new -host-side enforcement model for native Windows sandboxes. It also commits -OpenShell to a set of dependencies on the Microsoft MXC team. These decisions -deserve broad review and a durable record. - -If we leave the current design unchanged, OpenShell remains Linux-only in -practice, Windows users are pushed toward heavyweight VM-based workarounds, and -the Windows work continues to live outside the public project. +Docker Desktop and WSL2 add a Linux VM to Windows workflows. MXC provides a +native AppContainer and ProcessContainer boundary, but the earlier +supervisor-free prototype duplicated lifecycle, credential, forwarding, and +proxy behavior in the driver and a workload relay. That duplicated security +protocols and diverged from sandbox authentication introduced in the common +runtime. + +Reusing RFC 0012 keeps Windows backend-specific code at the isolation edge and +preserves one supervisor session model across Docker, Podman, Kubernetes, VM, +and MXC. It also lets forwarding and provider credential refresh use existing +authenticated paths instead of MXC-only side channels. ## Non-goals -- Porting `openshell-sandbox` (the Linux supervisor) to Windows, or shipping any - in-sandbox OpenShell binary. This RFC rejects that path for native Windows. -- Making Windows a Docker, Podman, Kubernetes, or VM runtime host. Those drivers - remain compile-only configuration stubs that return an unsupported error. -- Starting MXC sandboxes from OCI images or Dockerfiles in the MVP. MXC runs - against the host Windows OS with policy/configuration, not a separate Linux - container image. -- Named-pipe driver IPC, a cross-process MXC driver binary, or a tonic - `ComputeDriverService` adapter for MXC. The driver is in-process. -- Full L7/port/binary-scoped policy enforcement inside MXC itself. MXC network - filtering is host/IP/CIDR-level; rich policy stays on the host proxy. -- MSI/WinGet packaging, installer UX, auto-start, and background gateway - management. -- GPU passthrough into MXC sandboxes. -- Changing Linux or macOS build, runtime, or driver behavior. All Windows code is - gated behind `cfg(target_os = "windows")`. +- Supporting Docker, Kubernetes, Podman, VM, WSL, or Hyper-V compute drivers on + Windows. +- Supporting MXC `isolation_session`; the initial runtime requires + `process_container`. +- Starting Windows sandboxes from OCI images. +- MSI, WinGet, Windows service, or background gateway installation. +- GPU passthrough. +- Full terminal resize before a Windows ConPTY implementation is available. +- Durable recovery of live MXC generations after gateway restart. ## Proposal -### Layered architecture - -OpenShell on Windows is a four-layer stack with a single hard trust boundary at -the MXC sandbox. For the current scope, the gateway runs as a user-launched -native Windows process. The MXC compute driver and per-sandbox host CONNECT proxy -tasks live inside that process. The agent runs inside an MXC AppContainer with -all egress redirected to its assigned host proxy listener. +### Runtime composition ```mermaid -flowchart LR - clients["Clients
CLI · TUI · SDK"] - upstreams["Internet / configured upstreams"] - - subgraph host["Windows host"] - direction LR - - subgraph gateway["openshell-gateway.exe - one native Windows process"] - direction TB - - control["Control plane
auth · sandbox state · policy · audit"] - driver["openshell-driver-mxc (in-process)
one backend · N sandbox entries"] +flowchart TD + Gateway[Gateway / in-process MXC driver] + Supervisor[openshell-supervisor
role=isolation-backend] + Sandbox[openshell-windows-sandbox
inside MXC ProcessContainer] + Workload[Workload process tree] + + Gateway -->|policy + launch authentication| Supervisor + Gateway -->|MXC config + one-use bootstrap| Sandbox + Supervisor <-->|generation-scoped TLS + sandbox JWT| Sandbox + Sandbox --> Workload +``` - subgraph proxies["Per-sandbox host proxy tasks and listeners (N)"] - direction TB - proxy_a["Proxy A
127.0.0.1:port_A
policy A · agent identity A"] - proxy_n["Proxy N
127.0.0.1:port_N
policy N · agent identity N"] - end +The driver owns provisioning and pairwise lifecycle monitoring. If either the +host supervisor or ProcessContainer exits unexpectedly, the driver terminates +the other. Stop and delete wait for pair termination before publishing success. +The gateway treats the standard supervisor session, not a driver-specific port +probe, as runtime readiness. + +### Outer fence and confirmation + +MXC receives the mapped filesystem, UI, and network constraints before +`openshell-windows-sandbox` starts. The boundary consumes and deletes its one-use +configuration and TLS private key before releasing workload code. It confirms +the ProcessContainer generation, resource claims, filesystem fence, egress +fence, authenticated control transport, and controller-loss behavior through +the backend-neutral isolation contract. + +The boundary terminates owned workload processes if no authenticated supervisor +recovers within the bounded reconnect deadline. Host auth bundles and runtime +descriptors are stored beneath an owner-only Windows DACL. + +### Networking and credentials + +MXC denies direct Internet egress and permits the loopback route used by the +Sandbox Protocol and explicit proxy. The host supervisor owns a distinct proxy +listener and random authorization value for every sandbox generation. +`openshell-windows-sandbox` injects the proxy URL and public CA paths only into workload +children. The supervisor retains private CA keys and provider secrets, applies +network policy, and refreshes provider state through the ordinary session. + +The listener rejects missing, duplicate, malformed, and cross-generation proxy +authorization before policy evaluation. The initial implementation assigns +requests to the admitted main workload binary because Windows socket-owner +identity is not yet carried by the explicit-proxy transport. Policies that rely +on different network rights for descendant executables are therefore outside +the initial enforcement contract. The loopback exception also does not isolate +unrelated services bound to `127.0.0.1`; the gateway host remains trusted. + +### Process lifecycle and forwarding + +The Windows boundary implements authenticated start, exec, attach, wait, +signal, terminate, retained stdout/stderr, provider environment refresh, and +loopback connect operations. Standard gateway dynamic forwarding reaches the +target through `BoundaryLoopbackConnector`; there is no reverse WebSocket, +stdin/stdout JSON protocol, or MXC-specific relay binary. + +ProcessContainer teardown remains the outer kill boundary. ConPTY terminal +resize is deferred; non-terminal exec and byte-stream I/O are supported first. + +### Configuration and packaging + +The Windows release contains `openshell-gateway.exe`, `openshell.exe`, +`openshell-supervisor.exe`, and `openshell-windows-sandbox.exe`. The runtime binaries +default to siblings of the gateway and may be overridden for development. +Gateway TLS uses the gateway-owned guest certificate bundle. + +The MXC driver configuration contains only host/runtime settings. Workload +command, working directory, environment, and policy stay sandbox-scoped. +Relay paths, relay target ports, and driver-owned proxy enable/seed settings are +removed. - control --> driver - driver -->|"owns HostProxyHandle A"| proxy_a - driver -->|"owns HostProxyHandle N"| proxy_n - end +## Implementation plan - wxc["wxc-exec.exe invocation(s)"] +1. Make the sandbox, supervisor, and supervisor-process crates compile on + Windows without enabling Linux-only controls. +2. Add the Windows Sandbox Protocol boundary and MXC confirmation evidence. +3. Provision the host supervisor and in-ProcessContainer sandbox as one + generation from the MXC driver. +4. Reuse the supervisor network and process sessions for forwarding, + credentials, exec, output, and controller-loss handling. +5. Remove the relay crate and MXC-only forwarding/credential side channels. +6. Build all four Windows binaries on x64 and ARM64, then validate on a native + MXC host. - subgraph sandboxes["MXC AppContainers (N)"] - direction TB - sandbox_a["Sandbox A
agent workload A only
no OpenShell supervisor"] - sandbox_n["Sandbox N
agent workload N only
no OpenShell supervisor"] - end +## Risks - driver -->|"launch and configure"| wxc - wxc -->|"creates and runs"| sandbox_a - wxc -->|"creates and runs"| sandbox_n +- MXC and AppContainer networking can differ across Windows preview builds. + Native-host qualification remains required in addition to cross-compilation. +- The explicit proxy cannot yet distinguish descendant executable identities. + This limitation is documented and must fail review for policies that require + per-child network separation until socket-owner attribution is added. +- Loopback transport exposes unrelated host listeners to the AppContainer if + those listeners lack their own authentication. OpenShell listeners always + require generation-scoped credentials, but operators must treat the host as + trusted. +- Gateway restart recovery is not durable. Orphan discovery and persisted + generation reconciliation are follow-up work. +- Windows process-tree and terminal semantics differ from Unix. The + ProcessContainer remains the final teardown boundary while ConPTY support is + incomplete. - sandbox_a -.->|"MXC network.proxy
localhost:port_A"| proxy_a - sandbox_n -.->|"MXC network.proxy
localhost:port_N"| proxy_n - end +## Alternatives - clients -->|"gRPC + mTLS"| control - proxy_a -->|"policy-filtered egress"| upstreams - proxy_n -->|"policy-filtered egress"| upstreams -``` +### Driver-owned relay and proxy -The gateway-to-sandbox relationship is 1:N for control and lifecycle, but the -proxy-listener-to-sandbox relationship is 1:1. Each sandbox registry entry owns -one `HostProxyHandle`, one sandbox-specific policy, and one unique ephemeral -loopback listener. The listener identifies the sandbox without attributing -connections arriving on a shared proxy port. - -The defining property is that OpenShell network enforcement lives on the host -inside the gateway process, not inside the sandbox. The current host-mode path -provides L4 policy and plaintext/forward-proxy L7 handling. HTTPS MITM trust -bootstrap, inference/privacy routing, and gateway event-bus wiring remain -follow-up work. - -This still allows the existing supervisor networking code to be reused as a -host-side proxy component. The boundary is that Windows does not run -`ConnectSupervisor`, a sandbox relay, or any OpenShell process inside the MXC -sandbox. - -### Part 1 - Native Windows build - -This effort compiles the gateway and CLI for -`x86_64-pc-windows-msvc` and `aarch64-pc-windows-msvc` while keeping Linux and -macOS unchanged. The dominant change is a consistent cfg-gating pattern: each -crate whose implementation is Unix-specific moves its Unix body into a -`*_unix.rs` module and adds a small `windows.rs` (or stub), preserving public -library entry points and configuration structs on both platforms. - -- Unsupported drivers (Docker, Podman, Kubernetes, VM) become compile-only - configuration stubs. The gateway still parses existing config files for every - driver name and returns a clear unsupported error at gateway construction - (Docker/Kubernetes/Podman) or at spawn (VM), never a parse failure or silent - no-op. -- `openshell-core/build.rs` selects a vendored `protoc` per platform - (`protoc-bin-vendored` on Windows, `protobuf-src` elsewhere) so proto - compilation succeeds under MSVC. -- Windows path defaults resolve configuration under `%APPDATA%` and state/data - under `%LOCALAPPDATA%`. -- Validation runs through a dedicated `mise` lane (`tasks/windows.toml` → - `tasks/scripts/windows-msvc.ps1`) invoked as `mise run --skip-tools windows:*`, - separate from the default Linux `ci` task. The wrapper discovers Visual - Studio's `VsDevCmd.bat`, adds rustup MSVC targets, and clears inherited - `RUSTC_WRAPPER`. -- A `windows-msvc` GitHub Actions job runs x64 check/build/test plus the - unsupported-driver contract tests on `windows-2025`; ARM64 is scaffolded and - disabled until an ARM64 runner is available. - -The Windows build target intentionally includes both `openshell.exe` and -`openshell-gateway.exe`. Running the gateway only as a Linux container while a -remote Windows MXC driver manages native sandboxes would keep the main repo's -Windows target smaller, but it would reintroduce a Linux container/VM dependency -and does not meet the native, low-overhead client-system goal of this RFC. - -### Part 2 - The MXC compute driver - -`openshell-driver-mxc` is a library crate entirely behind -`cfg(target_os = "windows")` (an empty shell elsewhere). It is linked -in-process into `openshell-server` and implements a plain Rust `ComputeBackend` -trait — there is no separate binary, no surrogate, and no tonic adapter. - -| Module | Responsibility | -|---|---| -| `driver.rs` (`MxcComputeBackend`) | Orchestrator: owns the registry, validates specs, runs the lifecycle, drives `wxc-exec` phases, runs the agent, resolves provider credentials (Windows Credential Manager) and injects them, self-reports readiness, emits watch events. | -| registry | In-memory `Arc>` mapping OpenShell sandbox id/name ⇄ MXC session id + phase state + exec/PTY handles. Source of truth for Get/List/Watch (MXC has no list-sessions API). | -| `mxc.rs` | Builds MXC config JSON, base64-encodes it, runs `wxc-exec`, parses envelopes; encapsulates exec-vs-non-exec stdout semantics and error-code mapping. | -| `policy.rs` | Translates the `SandboxPolicy` proto → MXC config and rejects unenforceable rules. Delegates to the embedded policy mapper (see Part 3). | -| `openshell-supervisor-network::host` | Starts one host CONNECT proxy task per sandbox on a unique `127.0.0.1:` listener, applies that sandbox's trimmed network policy and static agent identity, and retains its `HostProxyHandle` in the driver registry. | - -#### Workload and software availability - -MXC does not consume the OCI image model used by Linux container runtimes. For current support, the sandbox runs Windows -software already present on the host or made available through explicit MXC -filesystem grants, with the driver supplying the agent command, working -directory, environment, credentials, and policy-derived MXC configuration. - -That is different from Linux, where a sandbox image can carry a separate userland -and dependency set. The MXC `processcontainer` and AppContainer paths share the -host Windows OS; the policy/configuration creates the isolation boundary. If MXC -later grows a Windows VM-backed image model, OpenShell can add a separate -bootstrap/image workflow for that backend. - -#### The `wxc-exec` interface contract - -The `mxc.rs` invoker is the boundary to MXC. Invocation is always -`wxc-exec.exe --config-base64 --experimental [--debug]`; -`configurationId` defaults to `composable` (never `small` — a known OS bug). The -invoker must branch on phase for I/O semantics: - -| Phase(s) | stdout | Exit code | Parse as | -|---|---|---|---| -| `provision` / `start` / `stop` / `deprovision` | single JSON envelope `{"result":…}` or `{"error":…}` | 0 success / 1 error | JSON envelope | -| `exec` | live process output (not JSON) | the script's exit code | raw bytes / stream | - -`provision` returns the session id to capture. MXC `error.code` values -(`not_provisioned`, `already_started`, `policy_validation`, -`backend_unavailable`, …) map to typed errors. A non-zero `exec` exit is the -script's result, not a driver error. - -#### State model and lifecycle - -MXC has no remote inventory API, so the in-memory registry is the single source -of truth. +The prototype launched a relay inside MXC and implemented forwarding, +credentials, readiness, and proxy lifecycle in the driver. It reduced the +initial Windows porting work but created a second security protocol and repeated +existing supervisor patterns. This proposal removes it. -```mermaid -stateDiagram-v2 - [*] --> Pending: CreateSandbox (validated, reserved) - Pending --> Provisioned: wxc-exec provision (capture session id) - Provisioned --> Started: wxc-exec start - Started --> Ready: wxc-exec exec (agent running) — driver self-reports - Ready --> Stopped: StopSandbox (wxc-exec stop) - Stopped --> Deleted: DeleteSandbox (wxc-exec deprovision) - Pending --> Failed: provision/start/exec error - Failed --> Deleted: cleanup - Deleted --> [*] -``` +### Supervisor-only host proxy without `openshell-windows-sandbox` -`Ready` is self-reported once the agent launches; it does not depend on any -supervisor connection. Every transition emits a `WatchSandboxes` event. -`CreateSandbox` translates policy → MXC config, resolves and injects credentials, -then runs `provision → start → exec`. Live `connect`/`exec` spawns a fresh -`wxc-exec phase=exec` in a ConPTY and bridges the gateway's bidi stream to its -stdin/stdout — no `ConnectSupervisor`, no in-sandbox SSH server, no relay socket. - -There is currently no reconciliation loop in the MVP. If an operator deletes an -OpenShell-managed MXC/AppContainer resource outside OpenShell, `get`, `list`, and -`watch` will continue to reflect the driver's registry until a later operation -touches the missing MXC resource and can mark the sandbox failed or not found. -Durable reconciliation is follow-up work: persist the OpenShell sandbox id ⇄ MXC -session id mapping in SQLite, probe or deprovision known sessions on startup, and -add a periodic reconcile loop when MXC exposes a list/inspect API. - -#### Governed egress - -Governed egress is the core value layer. When it is enabled, -`egress_proxy_addr` serves as a loopback address seed. For each sandbox, the MXC -driver preserves the configured IP and binds port `0` to allocate a fresh -ephemeral port. It starts the existing OpenShell host CONNECT proxy with that -sandbox's trimmed network-only policy, then writes the allocated port to MXC's -`network.proxy = { localhost: N }` redirect. Loopback inside an AppContainer is -host loopback, so sandbox egress reaches the proxy running in the in-process MXC -driver inside the gateway process. - -The host-listener-to-sandbox topology is **1:1**, not many-to-one. Multiple -sandboxes share `127.0.0.1`, but each active sandbox owns a unique ephemeral port -and host proxy handle in the driver registry. The resulting -`127.0.0.1:` tuple scopes every inbound proxy connection to exactly one -sandbox, eliminating the need to infer sandbox identity from shared loopback -traffic. Dropping the handle when the sandbox stops, exits, or fails terminates -that sandbox's proxy accept loop. - -Because MXC does not expose Linux procfs socket ownership, the proxy evaluates -each connection against the listener's sandbox policy and a static sandbox-agent -identity derived from the configured `agent_command`. The current host-mode path -evaluates L4 host:port allow/deny via OPA, handles plaintext and forward-proxy L7 -traffic, and emits OCSF events. HTTPS MITM trust bootstrap and gateway -denial/activity bus wiring remain follow-up work. The default `processcontainer` -backend already honors `network.proxy`, so this design requires no MXC changes. - -### Part 3 - Policy translation between OpenShell and MXC - -OpenShell policy is authored as YAML and parsed to the `SandboxPolicy` proto by -the shared cross-platform `openshell-policy` crate. The MXC driver does not -re-parse YAML; a dedicated Rust policy mapper (embedded in the driver and called -automatically) maps the proto IR to MXC `ContainerConfig` and **rejects rather -than silently drops** anything MXC cannot enforce. MXC imposes a provision-time -vs exec-time split. - -| OpenShell policy | Where enforced | MXC mapping | When | -|---|---|---|---| -| filesystem read/write paths | MXC | `filesystem.readwritePaths` | provision | -| filesystem read-only paths | MXC | `filesystem.readonlyPaths` | provision | -| filesystem denied paths | MXC | limited / unsupported | provision | -| process (uid/gid/seccomp) | — (no analog) | reject (default) | — | -| network (OPA / L7 / inference / privacy) | host CONNECT proxy | `network.proxy = { localhost: N }` redirect | provision | - -The primary governed-egress design does **not** try to map the full OpenShell -network policy into MXC network policy. MXC receives a fail-closed redirect -layer: `network.defaultPolicy = "block"`, empty direct allowlists, and -`network.proxy = { localhost: N }`. The original OpenShell `network_policies` -are preserved and handed to the host CONNECT proxy, which remains responsible -for ports, binaries, L7 rules, privacy routing, and audit. - -The coarse MXC-only mapper is a separate fallback and analysis path for cases -where no proxy is in the loop. In that mode, MXC can roughly express literal -host/IP/CIDR allowlists, but it cannot encode ports, protocols, per-binary scope, -TLS inspection behavior, credential rewrite, inference routing, or REST, -WebSocket, and GraphQL rules. The mapper emits a structured loss report and -rejects error-severity losses rather than silently broadening access. Critically, -MXC defaults to `defaultPolicy: "allow"` when the network block is omitted, so -both paths must explicitly emit `network.defaultPolicy: "block"`. - -Across the five OpenShell example policies, the MXC-only coarse mapping is -schema-valid but lossy: an aggregate of 64 access-broadening errors, 32 -warnings, and 4 info items. The dominant gaps are binary-scoped network policy, -port-scoped outbound policy, protocol-aware (REST/WebSocket/GraphQL) policy, and -access presets. Those losses do not apply to the governed-egress split because -the host CONNECT proxy receives and enforces the original OpenShell network -policy. - -To consume OpenShell policy more faithfully over time, MXC would need -kernel-enforceable additions such as port-scoped network endpoints, a filesystem -`defaultPolicy`, per-process/binary network scoping, and DNS/wildcard handling. A -proposed two-surface direction for Microsoft keeps `ContainerConfig` as the -execution manifest (add portable kernel-enforceable fields such as ports and -filesystem `defaultPolicy`) and adds a separate `policyProxy` surface for L7 and -dynamic policy so HTTP/WebSocket/GraphQL parsing, credential rewrite, audit, and -hot-reload stay out of every backend runner. None of these are required for the -host-enforced design proposed here; they are enhancements that would deepen -kernel-level defense-in-depth. - -### Design decisions (D1–D4) - -- **D1 — MXC as the Windows sandbox primitive**, over Docker Desktop, WSL2, and - Windows Sandbox/Hyper-V. MXC is OS-native, needs no VM, and runs unelevated. - Default backend `processcontainer`; `isolation_session` opt-in. Requires - Windows 11 build ≥ 26100 and `wxc-exec.exe` present. -- **D2 — Reject porting the supervisor for native Windows.** Use a host proxy + - MXC `network.proxy` redirect for governed egress, plus driver-owned host-side - behavior for credentials and exec. No in-sandbox OpenShell binary is part of - this RFC. Consequence: governed egress on the opt-in `isolation_session` - backend depends on Microsoft extending `network.proxy` to that backend; until - then the design defaults to `processcontainer`, where it works today. -- **D3 — User-launched native Windows gateway for the current scope.** Run - `openshell-gateway.exe` as a regular user process. Clients connect over gRPC - (loopback or remote mTLS), and existing per-user configuration and state paths - remain in effect. Installation, auto-start, background process management, - and Windows Event Log integration are outside this RFC. -- **D4 — Reduce the gRPC footprint to the client-facing API only.** Supervisor - removal deletes the supervisor and sandbox-relay boundaries; in-process MXC - removes the wire protocol on the gateway↔driver boundary. Only client↔gateway - gRPC survives on Windows. +Running only the host supervisor cannot provide authenticated in-boundary +process lifecycle, retained I/O, controller-loss handling, or loopback target +connection. It also leaves the driver responsible for these behaviors. -## Implementation plan +### Windows VM or WSL2 -All Windows code is gated behind `cfg(target_os = "windows")`, so Linux and macOS -are never affected and the changes can land additively. - -- **Compile.** Land the MSVC cfg-gating, per-platform `protoc` selection, - Windows path defaults, the `mise` Windows lane, and the `windows-msvc` CI job. - Unsupported drivers become contract stubs with tests asserting they return - unsupported. -- **MXC driver and host proxy.** Add `openshell-driver-mxc` driving the default - `processcontainer` backend: lifecycle, policy translation, one host CONNECT - proxy listener per sandbox, credential injection, and interactive exec via - the driver's ConPTY bridge. Unenforceable policy is rejected in - `ValidateSandboxCreate` with `invalid_argument` naming the rule. HTTPS MITM, - inference/privacy routing, and gateway event-bus wiring follow after host-mode - trust bootstrap is available. -- **Gateway runtime.** Run `openshell-gateway.exe` directly as a regular user - process with the existing per-user configuration, SQLite, TLS, and logging - paths. Installation, auto-start, and background process management are - follow-up work. -- **Hardening.** Validate collision-free per-sandbox ephemeral port allocation - and `processcontainer` concurrency. Persist the sandbox-id ⇄ session-id - mapping so a gateway restart can reconcile or clean up orphaned sessions, and - add a periodic reconcile loop once MXC exposes a list/inspect API. -- **Opt-in `isolation_session` egress.** Becomes available if and when Microsoft - extends `network.proxy` to that backend; the same host proxy then governs its - egress. - -Validation follows a layered pyramid: pure-Rust unit tests for the JSON -builders/parsers and policy mapper (on the Windows MSVC test lane), a mock -`wxc-exec` shim (`OPENSHELL_MXC_MOCK_WXC=1`) for lifecycle logic, egress-proxy -component tests for redirect → listener-scoped policy → OPA decision, gated -integration tests against a real `wxc-exec` (`#[ignore]` unless present), and -manual E2E on a real Windows 11 host. User-facing configuration is documented in -the gateway config reference and the architecture docs. +The existing Linux runtime can run inside a VM, but that does not deliver the +native, low-overhead Windows isolation workflow this RFC targets. -## Risks +### Do nothing -| Risk | Mitigation | -|---|---| -| MXC `allowedHosts`/`blockedHosts` not enforced on Windows yet, so there is no kernel-level defense-in-depth beneath the host proxy. | Rely on the host proxy for host-level allow/deny | -| Per-sandbox proxy routing must remain collision-free when multiple sandboxes run concurrently. | Bind a fresh ephemeral port on `127.0.0.1` for each sandbox and retain its proxy handle in the driver registry, making the listener-to-sandbox mapping 1:1. | -| `--config-base64` carries credentials in argv (briefly visible in process listings). | Zero-fill after invocation; prefer passing config on stdin. | -| Concurrency: `isolation_session` is single-session; `processcontainer` limits are unverified. | Validate `processcontainer` concurrency and document any cap. | -| OCSF fidelity: with no in-sandbox supervisor, arbitrary in-process events are not visible (only network + lifecycle). | Accept reduced fidelity; an ETW/callback hook from the Microsoft MXC team will help restore in-process visibility later. | -| Restart and external deletion: the registry is in-memory, so a gateway restart or out-of-band MXC deletion is not immediately reflected in OpenShell state. | Persist the sandbox-id ⇄ session-id mapping in SQLite, reconcile or deprovision orphans on startup, and add periodic reconcile when MXC exposes list/inspect. | -| Policy fidelity: MXC cannot enforce port/binary/L7 policy, so the MXC-only tier is a coarse approximation. | Fail-safe mapper (always `block`, never silently broaden) + host proxy as the real enforcer + a published loss report. | -| Microsoft dependency: several deepening improvements are outside OpenShell's control. | Ship the host-enforced design with no MXC changes required; treat MXC enhancements as optional, not blockers. | - -## Alternatives Considered - -- **Run OpenShell on Windows via Docker Desktop, WSL2, or Hyper-V/Windows - Sandbox.** Reuses the existing Linux drivers unchanged, but reintroduces a - Linux VM, heavier install/network complexity, licensing constraints, and (for - Hyper-V/Windows Sandbox) elevation. It defeats the goal of OS-native, - unelevated Windows isolation. -- **Port `openshell-sandbox` to Windows (in-sandbox supervisor).** Maximizes - Linux parity and defense-in-depth, but requires Windows analogs of - Landlock/seccomp/netns, a Windows relay protocol, and an in-sandbox binary — - far more surface for the same user-visible feature set, which the host-proxy - design already delivers. This RFC rejects that path; any future - defense-in-depth revisit should be a new design rather than assumed follow-up - work. -- **Out-of-tree remote MXC driver with a containerized Linux gateway.** This - would reduce the main repo's Windows build surface to the CLI if the gateway - could stay containerized. It does not meet this RFC's native Windows goal: - the MXC driver needs Windows-host access to `wxc-exec`, AppContainer/MXC - state, loopback proxy routing, and Windows credentials, while a Linux - containerized gateway would reintroduce the VM/container dependency this RFC - is removing. It would also require extending the remote driver protocol to - carry policy and proxy state that the current in-process design can share - directly. -- **Cross-compile the Windows binaries from Linux only.** Cheaper CI, but cannot - validate runtime correctness on real Windows hardware, which is essential for - MXC integration. -- **A cross-process MXC driver binary (like the VM driver) with a tonic - adapter.** Matches the existing VM driver shape, but adds a wire protocol and a - second process for no benefit when the driver can be linked in-process and the - supervisor is gone. -- **Do nothing.** OpenShell stays Linux-only in practice and Windows users rely - on VM-based workarounds. +Keeping the supervisor-free prototype would preserve protocol duplication and +make sandbox authentication, forwarding, credential rotation, and policy fixes +diverge by platform. ## Prior art -- **OpenShell's existing compute drivers** (Docker, Podman, Kubernetes, VM) - establish the `ComputeDriver`/`ComputeBackend` contract and the - driver-selection model this RFC extends with an in-process, supervisor-free - variant. -- **RFC 0001 (core architecture)** and `architecture/sandbox.md` define the - supervisor/relay model that the Windows design deliberately removes. -- **Microsoft MXC (`wxc-exec`)** provides the Windows AppContainer-based - sandbox primitive and the `network.proxy` redirect that makes host-side - enforcement possible without an in-sandbox agent. -- **Open Policy Agent and OpenShell's CONNECT proxy / L7 / inference / privacy - stack** are reused unchanged on the host, demonstrating that the value layers - are already cross-platform Rust. +- RFC 0012 defines the backend-neutral isolation contract and authenticated + supervisor/boundary pairing used here. +- The VM backend already runs the supervisor on the host while a capability-free + boundary runs inside a stronger isolation primitive. +- Docker, Podman, and Kubernetes use the same supervisor session for network + policy, credentials, lifecycle, and forwarding. +- Windows AppContainer and ProcessContainer provide the native outer fence but + not OpenShell's application-layer policy semantics. + +## Open questions + +- Which Windows API should provide race-resistant socket-owner executable + identity for per-descendant binary network policy? +- Should MXC restart recovery persist enough generation metadata to reconnect, + or should startup always terminate and recreate orphaned ProcessContainers? +- What ConPTY surface is required before Windows interactive exec is considered + complete? diff --git a/sdk/go/openshell/v1/fake/sandbox.go b/sdk/go/openshell/v1/fake/sandbox.go index 2932457c25..965e11b0dc 100644 --- a/sdk/go/openshell/v1/fake/sandbox.go +++ b/sdk/go/openshell/v1/fake/sandbox.go @@ -79,6 +79,10 @@ func copySandboxPolicy(p *types.SandboxPolicy) *types.SandboxPolicy { pr := *p.Process cp.Process = &pr } + if p.UI != nil { + ui := *p.UI + cp.UI = &ui + } if p.NetworkPolicies != nil { np := make(map[string]types.NetworkPolicyRule, len(p.NetworkPolicies)) for k, rule := range p.NetworkPolicies { diff --git a/sdk/go/openshell/v1/fake/sandbox_test.go b/sdk/go/openshell/v1/fake/sandbox_test.go index 4329652c13..526cf4319a 100644 --- a/sdk/go/openshell/v1/fake/sandbox_test.go +++ b/sdk/go/openshell/v1/fake/sandbox_test.go @@ -843,6 +843,11 @@ func TestFakeSandboxCreateWithPolicy(t *testing.T) { RunAsUser: "sandbox", RunAsGroup: "sandbox-group", }, + UI: &types.UIPolicy{ + AllowGraphicalUI: true, + Clipboard: types.UIClipboardAccessRead, + AllowInputInjection: true, + }, NetworkPolicies: map[string]types.NetworkPolicyRule{ "web": { Name: "web", @@ -880,6 +885,10 @@ func TestFakeSandboxCreateWithPolicy(t *testing.T) { require.NotNil(t, p.Process) assert.Equal(t, "sandbox", p.Process.RunAsUser) assert.Equal(t, "sandbox-group", p.Process.RunAsGroup) + require.NotNil(t, p.UI) + assert.True(t, p.UI.AllowGraphicalUI) + assert.Equal(t, types.UIClipboardAccessRead, p.UI.Clipboard) + assert.True(t, p.UI.AllowInputInjection) require.Len(t, p.NetworkPolicies, 1) webRule, ok := p.NetworkPolicies["web"] @@ -892,19 +901,23 @@ func TestFakeSandboxCreateWithPolicy(t *testing.T) { // Deep-copy isolation: mutate input spec, verify stored copy unchanged spec.Policy.Version = 99 spec.Policy.Filesystem.ReadOnly[0] = "mutated" + spec.Policy.UI.Clipboard = types.UIClipboardAccessAll spec.Policy.NetworkPolicies["web"] = types.NetworkPolicyRule{Name: "mutated"} got2, err := sc.Get(ctx, "default", "policy-sb") require.NoError(t, err) assert.Equal(t, uint32(3), got2.Spec.Policy.Version) assert.Equal(t, "/etc", got2.Spec.Policy.Filesystem.ReadOnly[0]) + assert.Equal(t, types.UIClipboardAccessRead, got2.Spec.Policy.UI.Clipboard) assert.Equal(t, "web", got2.Spec.Policy.NetworkPolicies["web"].Name) // Deep-copy isolation: mutate returned object, verify store unchanged got.Spec.Policy.Filesystem.ReadWrite[0] = "mutated" + got.Spec.Policy.UI.AllowInputInjection = false got3, err := sc.Get(ctx, "default", "policy-sb") require.NoError(t, err) assert.Equal(t, "/tmp", got3.Spec.Policy.Filesystem.ReadWrite[0]) + assert.True(t, got3.Spec.Policy.UI.AllowInputInjection) } func TestFakeSandboxCreateWithNilPolicy(t *testing.T) { diff --git a/sdk/go/openshell/v1/gateway/gateway.go b/sdk/go/openshell/v1/gateway/gateway.go index da7c9814b4..dde3545664 100644 --- a/sdk/go/openshell/v1/gateway/gateway.go +++ b/sdk/go/openshell/v1/gateway/gateway.go @@ -135,7 +135,7 @@ func ListGateways() ([]Info, error) { } } - sysNames, listErr := listGatewayDirs(systemConfigBase) + sysNames, listErr := listGatewayDirs(systemConfigDir()) if listErr != nil { return nil, listErr } diff --git a/sdk/go/openshell/v1/gateway/gateway_test.go b/sdk/go/openshell/v1/gateway/gateway_test.go index ec519c29a6..e22a0a7873 100644 --- a/sdk/go/openshell/v1/gateway/gateway_test.go +++ b/sdk/go/openshell/v1/gateway/gateway_test.go @@ -409,6 +409,7 @@ func TestLoadConfig_ActiveGateway(t *testing.T) { func TestListGateways_MultipleGateways(t *testing.T) { tmp := t.TempDir() t.Setenv("XDG_CONFIG_HOME", tmp) + t.Setenv(systemGatewayDirEnv, tmp) for _, name := range []string{"prod", "staging", "dev"} { gwDir := filepath.Join(tmp, "openshell", "gateways", name) @@ -432,6 +433,7 @@ func TestListGateways_MultipleGateways(t *testing.T) { func TestListGateways_EmptyDirs(t *testing.T) { tmp := t.TempDir() t.Setenv("XDG_CONFIG_HOME", tmp) + t.Setenv(systemGatewayDirEnv, tmp) gateways, err := ListGateways() require.NoError(t, err) @@ -441,6 +443,7 @@ func TestListGateways_EmptyDirs(t *testing.T) { func TestListGateways_ActiveStatus(t *testing.T) { tmp := t.TempDir() t.Setenv("XDG_CONFIG_HOME", tmp) + t.Setenv(systemGatewayDirEnv, tmp) for _, name := range []string{"alpha", "beta"} { gwDir := filepath.Join(tmp, "openshell", "gateways", name) diff --git a/sdk/go/openshell/v1/gateway/paths.go b/sdk/go/openshell/v1/gateway/paths.go index fa0e1373c2..0fa9cb0092 100644 --- a/sdk/go/openshell/v1/gateway/paths.go +++ b/sdk/go/openshell/v1/gateway/paths.go @@ -24,6 +24,10 @@ const ( // systemConfigBase is the system-wide config directory. systemConfigBase = "/etc/openshell" + + // systemGatewayDirEnv overrides the system-wide config root. Keep this in + // sync with the Rust CLI so SDK discovery sees the same gateway set. + systemGatewayDirEnv = "OPENSHELL_SYSTEM_GATEWAY_DIR" ) // userConfigDir returns the user-specific configuration directory for @@ -49,7 +53,16 @@ func userConfigDir() (string, error) { // systemGatewayDir returns the system-wide gateway config directory. func systemGatewayDir() string { - return filepath.Join(systemConfigBase, gatewaySubdir) + return filepath.Join(systemConfigDir(), gatewaySubdir) +} + +// systemConfigDir returns the system-wide configuration root. Empty and +// relative overrides are ignored to match the CLI's fail-safe behavior. +func systemConfigDir() string { + if dir := os.Getenv(systemGatewayDirEnv); dir != "" && filepath.IsAbs(dir) { + return dir + } + return systemConfigBase } // resolveGatewayDir searches for a gateway directory by name, checking the diff --git a/sdk/go/openshell/v1/gateway/paths_test.go b/sdk/go/openshell/v1/gateway/paths_test.go index fff9a01d73..60a91569ea 100644 --- a/sdk/go/openshell/v1/gateway/paths_test.go +++ b/sdk/go/openshell/v1/gateway/paths_test.go @@ -39,6 +39,22 @@ func TestSystemGatewayDir(t *testing.T) { assert.Equal(t, filepath.FromSlash("/etc/openshell/gateways"), dir) } +func TestSystemGatewayDirOverride(t *testing.T) { + tmp := t.TempDir() + t.Setenv(systemGatewayDirEnv, tmp) + + assert.Equal(t, filepath.Join(tmp, "gateways"), systemGatewayDir()) +} + +func TestSystemGatewayDirIgnoresInvalidOverrides(t *testing.T) { + for _, override := range []string{"", "relative/path"} { + t.Run(override, func(t *testing.T) { + t.Setenv(systemGatewayDirEnv, override) + assert.Equal(t, filepath.FromSlash("/etc/openshell/gateways"), systemGatewayDir()) + }) + } +} + func TestResolveGatewayDir_UserDir(t *testing.T) { tmp := t.TempDir() t.Setenv("XDG_CONFIG_HOME", tmp) diff --git a/sdk/go/openshell/v1/internal/converter/coverage_test.go b/sdk/go/openshell/v1/internal/converter/coverage_test.go index 43577d61d0..9df818daa9 100644 --- a/sdk/go/openshell/v1/internal/converter/coverage_test.go +++ b/sdk/go/openshell/v1/internal/converter/coverage_test.go @@ -171,6 +171,7 @@ func TestConverterCoversAllProtoFields_SandboxPolicy(t *testing.T) { "network_policies": true, "process": true, "landlock": true, + "ui": true, "network_middlewares": true, } diff --git a/sdk/go/openshell/v1/internal/converter/health.go b/sdk/go/openshell/v1/internal/converter/health.go index 8e1c2b98f6..c1cd07fea4 100644 --- a/sdk/go/openshell/v1/internal/converter/health.go +++ b/sdk/go/openshell/v1/internal/converter/health.go @@ -83,6 +83,7 @@ func ComputeDriverInfoFromProto(d *pb.ComputeDriverInfo) types.ComputeDriverInfo if caps := d.GetCapabilities(); caps != nil { result.DriverName = caps.GetDriverName() result.DriverVersion = caps.GetDriverVersion() + result.SupportsUIPolicy = caps.GetSupportsUiPolicy() } return result } diff --git a/sdk/go/openshell/v1/internal/converter/health_test.go b/sdk/go/openshell/v1/internal/converter/health_test.go index ab8e0c2415..3fc3a09646 100644 --- a/sdk/go/openshell/v1/internal/converter/health_test.go +++ b/sdk/go/openshell/v1/internal/converter/health_test.go @@ -20,8 +20,9 @@ func TestGatewayInfoFromProto(t *testing.T) { { Name: "k8s", Capabilities: &pb.ComputeDriverCapabilities{ - DriverName: "kubernetes", - DriverVersion: "2.1.0", + DriverName: "kubernetes", + DriverVersion: "2.1.0", + SupportsUiPolicy: true, }, }, { @@ -54,12 +55,14 @@ func TestGatewayInfoFromProto(t *testing.T) { assert.Equal(t, "k8s", info.ComputeDrivers[0].Name) assert.Equal(t, "kubernetes", info.ComputeDrivers[0].DriverName) assert.Equal(t, "2.1.0", info.ComputeDrivers[0].DriverVersion) + assert.True(t, info.ComputeDrivers[0].SupportsUIPolicy) assert.Equal(t, "docker", info.ComputeDrivers[1].Name) assert.Equal(t, "docker-engine", info.ComputeDrivers[1].DriverName) require.Len(t, info.Extensions, 1) assert.Equal(t, v1.ExtensionKindComputeDriver, info.Extensions[0].Kind) assert.Equal(t, "openshell/kubernetes", info.Extensions[0].ImplementationName) assert.Equal(t, uint32(1), info.Extensions[0].ProtocolMajor) + assert.False(t, info.ComputeDrivers[1].SupportsUIPolicy) } func TestGatewayInfoFromProto_NoDrivers(t *testing.T) { @@ -130,6 +133,7 @@ func TestComputeDriverInfoFromProto_NilCapabilities(t *testing.T) { assert.Equal(t, "bare-metal", info.Name) assert.Empty(t, info.DriverName) assert.Empty(t, info.DriverVersion) + assert.False(t, info.SupportsUIPolicy) } func TestCurrentUserFromProto(t *testing.T) { diff --git a/sdk/go/openshell/v1/internal/converter/policy.go b/sdk/go/openshell/v1/internal/converter/policy.go index 1a473654e9..7be4ecbb86 100644 --- a/sdk/go/openshell/v1/internal/converter/policy.go +++ b/sdk/go/openshell/v1/internal/converter/policy.go @@ -117,6 +117,7 @@ func SandboxPolicyFromProto(p *sbv1.SandboxPolicy) *types.SandboxPolicy { Filesystem: filesystemPolicyFromProto(p.GetFilesystem()), Landlock: landlockPolicyFromProto(p.GetLandlock()), Process: processPolicyFromProto(p.GetProcess()), + UI: uiPolicyFromProto(p.GetUi()), } if np := p.GetNetworkPolicies(); np != nil { result.NetworkPolicies = make(map[string]types.NetworkPolicyRule, len(np)) @@ -148,6 +149,7 @@ func SandboxPolicyToProto(p *types.SandboxPolicy) *sbv1.SandboxPolicy { Filesystem: filesystemPolicyToProto(p.Filesystem), Landlock: landlockPolicyToProto(p.Landlock), Process: processPolicyToProto(p.Process), + Ui: uiPolicyToProto(p.UI), } if p.NetworkPolicies != nil { result.NetworkPolicies = make(map[string]*sbv1.NetworkPolicyRule, len(p.NetworkPolicies)) @@ -184,6 +186,58 @@ func SandboxPolicyToProtoChecked(p *types.SandboxPolicy) (*sbv1.SandboxPolicy, e return result, nil } +func uiPolicyFromProto(p *sbv1.UiPolicy) *types.UIPolicy { + if p == nil { + return nil + } + return &types.UIPolicy{ + AllowGraphicalUI: p.GetAllowGraphicalUi(), + Clipboard: uiClipboardAccessFromProto(p.GetClipboard()), + AllowInputInjection: p.GetAllowInputInjection(), + } +} + +func uiPolicyToProto(p *types.UIPolicy) *sbv1.UiPolicy { + if p == nil { + return nil + } + return &sbv1.UiPolicy{ + AllowGraphicalUi: p.AllowGraphicalUI, + Clipboard: uiClipboardAccessToProto(p.Clipboard), + AllowInputInjection: p.AllowInputInjection, + } +} + +func uiClipboardAccessFromProto(v sbv1.UiClipboardAccess) types.UIClipboardAccess { + switch v { + case sbv1.UiClipboardAccess_UI_CLIPBOARD_ACCESS_NONE: + return types.UIClipboardAccessNone + case sbv1.UiClipboardAccess_UI_CLIPBOARD_ACCESS_READ: + return types.UIClipboardAccessRead + case sbv1.UiClipboardAccess_UI_CLIPBOARD_ACCESS_WRITE: + return types.UIClipboardAccessWrite + case sbv1.UiClipboardAccess_UI_CLIPBOARD_ACCESS_ALL: + return types.UIClipboardAccessAll + default: + return types.UIClipboardAccessUnspecified + } +} + +func uiClipboardAccessToProto(v types.UIClipboardAccess) sbv1.UiClipboardAccess { + switch v { + case types.UIClipboardAccessNone: + return sbv1.UiClipboardAccess_UI_CLIPBOARD_ACCESS_NONE + case types.UIClipboardAccessRead: + return sbv1.UiClipboardAccess_UI_CLIPBOARD_ACCESS_READ + case types.UIClipboardAccessWrite: + return sbv1.UiClipboardAccess_UI_CLIPBOARD_ACCESS_WRITE + case types.UIClipboardAccessAll: + return sbv1.UiClipboardAccess_UI_CLIPBOARD_ACCESS_ALL + default: + return sbv1.UiClipboardAccess_UI_CLIPBOARD_ACCESS_UNSPECIFIED + } +} + func middlewareConfigFromProto(m *sbv1.NetworkMiddlewareConfig) types.NetworkMiddlewareConfig { result := types.NetworkMiddlewareConfig{ Name: m.GetName(), diff --git a/sdk/go/openshell/v1/internal/converter/policy_test.go b/sdk/go/openshell/v1/internal/converter/policy_test.go index dde2205773..c6cf740d7d 100644 --- a/sdk/go/openshell/v1/internal/converter/policy_test.go +++ b/sdk/go/openshell/v1/internal/converter/policy_test.go @@ -200,6 +200,11 @@ func TestSandboxPolicyRoundTrip(t *testing.T) { RunAsUser: "sandbox-user", RunAsGroup: "sandbox-group", }, + UI: &v1.UIPolicy{ + AllowGraphicalUI: true, + Clipboard: v1.UIClipboardAccessRead, + AllowInputInjection: true, + }, NetworkPolicies: map[string]v1.NetworkPolicyRule{ "web-api": { Name: "web-api", @@ -239,6 +244,10 @@ func TestSandboxPolicyRoundTrip(t *testing.T) { assert.Equal(t, original.Process.RunAsUser, roundTrip.Process.RunAsUser) assert.Equal(t, original.Process.RunAsGroup, roundTrip.Process.RunAsGroup) + // UI + require.NotNil(t, roundTrip.UI) + assert.Equal(t, original.UI, roundTrip.UI) + // NetworkPolicies require.Len(t, roundTrip.NetworkPolicies, 2) webAPI, ok := roundTrip.NetworkPolicies["web-api"] @@ -315,6 +324,39 @@ func TestSandboxPolicyPartialSubPolicies(t *testing.T) { assert.Nil(t, roundTrip.NetworkPolicies) }) + t.Run("only UI", func(t *testing.T) { + for _, clipboard := range []v1.UIClipboardAccess{ + v1.UIClipboardAccessUnspecified, + v1.UIClipboardAccessNone, + v1.UIClipboardAccessRead, + v1.UIClipboardAccessWrite, + v1.UIClipboardAccessAll, + } { + original := &v1.SandboxPolicy{ + UI: &v1.UIPolicy{ + AllowGraphicalUI: true, + Clipboard: clipboard, + AllowInputInjection: true, + }, + } + roundTrip := SandboxPolicyFromProto(SandboxPolicyToProto(original)) + require.NotNil(t, roundTrip) + assert.Equal(t, original.UI, roundTrip.UI) + } + }) + + t.Run("explicit empty UI remains present", func(t *testing.T) { + original := &v1.SandboxPolicy{UI: &v1.UIPolicy{}} + protoPolicy := SandboxPolicyToProto(original) + require.NotNil(t, protoPolicy) + require.NotNil(t, protoPolicy.Ui) + + roundTrip := SandboxPolicyFromProto(protoPolicy) + require.NotNil(t, roundTrip) + require.NotNil(t, roundTrip.UI) + assert.Equal(t, &v1.UIPolicy{}, roundTrip.UI) + }) + t.Run("only landlock", func(t *testing.T) { original := &v1.SandboxPolicy{ Version: 2, diff --git a/sdk/go/openshell/v1/types/health.go b/sdk/go/openshell/v1/types/health.go index 3039f1c80d..1b112ace3c 100644 --- a/sdk/go/openshell/v1/types/health.go +++ b/sdk/go/openshell/v1/types/health.go @@ -54,9 +54,10 @@ type ExtensionInfo struct { // ComputeDriverInfo describes a compute backend available on the gateway. type ComputeDriverInfo struct { - Name string - DriverName string - DriverVersion string + Name string + DriverName string + DriverVersion string + SupportsUIPolicy bool } // CurrentUser holds the authenticated caller's identity. diff --git a/sdk/go/openshell/v1/types/policy.go b/sdk/go/openshell/v1/types/policy.go index 8fb0712b60..ab01014522 100644 --- a/sdk/go/openshell/v1/types/policy.go +++ b/sdk/go/openshell/v1/types/policy.go @@ -112,7 +112,7 @@ type DraftPolicy struct { // SandboxPolicy is the top-level security policy configuration for a sandbox. // It contains filesystem access rules, Landlock LSM configuration, process -// identity rules, and named network access policies. +// identity rules, portable UI capabilities, and named network access policies. type SandboxPolicy struct { // Version is the policy version number. The server may override this on write. Version uint32 @@ -125,6 +125,9 @@ type SandboxPolicy struct { // Process controls the user and group identity for sandboxed processes. // Nil means no process policy is specified. Process *ProcessPolicy + // UI controls portable graphical UI, clipboard, and input-injection capabilities. + // Nil means no UI policy is specified. + UI *UIPolicy // NetworkPolicies contains named network access rules. // Nil means no network policies are specified; an empty map is distinct from nil. NetworkPolicies map[string]NetworkPolicyRule @@ -178,6 +181,33 @@ type ProcessPolicy struct { RunAsGroup string } +// UIClipboardAccess controls host clipboard direction from the sandbox's perspective. +type UIClipboardAccess int + +const ( + // UIClipboardAccessUnspecified resolves to no clipboard access. + UIClipboardAccessUnspecified UIClipboardAccess = iota + // UIClipboardAccessNone denies clipboard reads and writes. + UIClipboardAccessNone + // UIClipboardAccessRead permits reading host clipboard contents. + UIClipboardAccessRead + // UIClipboardAccessWrite permits writing host clipboard contents. + UIClipboardAccessWrite + // UIClipboardAccessAll permits reading and writing host clipboard contents. + UIClipboardAccessAll +) + +// UIPolicy declares platform-neutral user-interface capabilities. +// Every zero value denies access. +type UIPolicy struct { + // AllowGraphicalUI permits the workload to display graphical windows. + AllowGraphicalUI bool + // Clipboard controls host clipboard direction. + Clipboard UIClipboardAccess + // AllowInputInjection permits synthetic keyboard or pointer input. + AllowInputInjection bool +} + // SandboxPolicyRevision represents a versioned policy revision for a sandbox. type SandboxPolicyRevision struct { // Version is the policy version (monotonically increasing per sandbox). diff --git a/sdk/go/proto/openshellv1/openshell.pb.go b/sdk/go/proto/openshellv1/openshell.pb.go index 109d2ef454..58dbf115b7 100644 --- a/sdk/go/proto/openshellv1/openshell.pb.go +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -1993,8 +1993,11 @@ type ComputeDriverCapabilities struct { DriverVersion string `protobuf:"bytes,2,opt,name=driver_version,json=driverVersion,proto3" json:"driver_version,omitempty"` // Static portable resource request forms reported by the driver. ResourceCapabilities *ResourceCapabilities `protobuf:"bytes,3,opt,name=resource_capabilities,json=resourceCapabilities,proto3" json:"resource_capabilities,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Whether the configured driver instance completely enforces the portable + // SandboxPolicy.ui contract. + SupportsUiPolicy bool `protobuf:"varint,4,opt,name=supports_ui_policy,json=supportsUiPolicy,proto3" json:"supports_ui_policy,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ComputeDriverCapabilities) Reset() { @@ -2048,6 +2051,13 @@ func (x *ComputeDriverCapabilities) GetResourceCapabilities() *ResourceCapabilit return nil } +func (x *ComputeDriverCapabilities) GetSupportsUiPolicy() bool { + if x != nil { + return x.SupportsUiPolicy + } + return false +} + // Static portable resource request forms reported by a compute driver. // An omitted domain means the driver does not report that domain. type ResourceCapabilities struct { @@ -11438,8 +11448,8 @@ type UpdateConfigRequest struct { // The new policy to apply. // // Sandbox scope (`global=false`): - // - only network_policies may differ from create-time - // policy; static fields must match version 1. + // - only network_policies may differ from create-time + // policy; static fields must match version 1. // // Global scope (`global=true`): // - applies to all sandboxes in full (no merge). @@ -17874,12 +17884,13 @@ const file_openshell_proto_rawDesc = "" + "\x15required_capabilities\x18\b \x03(\tR\x14requiredCapabilities\"t\n" + "\x11ComputeDriverInfo\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12K\n" + - "\fcapabilities\x18\x02 \x01(\v2'.openshell.v1.ComputeDriverCapabilitiesR\fcapabilities\"\xbc\x01\n" + + "\fcapabilities\x18\x02 \x01(\v2'.openshell.v1.ComputeDriverCapabilitiesR\fcapabilities\"\xea\x01\n" + "\x19ComputeDriverCapabilities\x12\x1f\n" + "\vdriver_name\x18\x01 \x01(\tR\n" + "driverName\x12%\n" + "\x0edriver_version\x18\x02 \x01(\tR\rdriverVersion\x12W\n" + - "\x15resource_capabilities\x18\x03 \x01(\v2\".openshell.v1.ResourceCapabilitiesR\x14resourceCapabilities\"\xca\x01\n" + + "\x15resource_capabilities\x18\x03 \x01(\v2\".openshell.v1.ResourceCapabilitiesR\x14resourceCapabilities\x12,\n" + + "\x12supports_ui_policy\x18\x04 \x01(\bR\x10supportsUiPolicy\"\xca\x01\n" + "\x14ResourceCapabilities\x127\n" + "\x03cpu\x18\x01 \x01(\v2%.openshell.v1.CpuResourceCapabilitiesR\x03cpu\x12@\n" + "\x06memory\x18\x02 \x01(\v2(.openshell.v1.MemoryResourceCapabilitiesR\x06memory\x127\n" + diff --git a/sdk/go/proto/sandboxv1/sandbox.pb.go b/sdk/go/proto/sandboxv1/sandbox.pb.go index c38db28c2f..938cfa7287 100644 --- a/sdk/go/proto/sandboxv1/sandbox.pb.go +++ b/sdk/go/proto/sandboxv1/sandbox.pb.go @@ -27,6 +27,67 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +// Directional clipboard access for a sandboxed workload. +type UiClipboardAccess int32 + +const ( + // Unspecified resolves to no clipboard access. + UiClipboardAccess_UI_CLIPBOARD_ACCESS_UNSPECIFIED UiClipboardAccess = 0 + // No clipboard reads or writes. + UiClipboardAccess_UI_CLIPBOARD_ACCESS_NONE UiClipboardAccess = 1 + // The sandbox may read host clipboard contents. + UiClipboardAccess_UI_CLIPBOARD_ACCESS_READ UiClipboardAccess = 2 + // The sandbox may write host clipboard contents. + UiClipboardAccess_UI_CLIPBOARD_ACCESS_WRITE UiClipboardAccess = 3 + // The sandbox may read and write host clipboard contents. + UiClipboardAccess_UI_CLIPBOARD_ACCESS_ALL UiClipboardAccess = 4 +) + +// Enum value maps for UiClipboardAccess. +var ( + UiClipboardAccess_name = map[int32]string{ + 0: "UI_CLIPBOARD_ACCESS_UNSPECIFIED", + 1: "UI_CLIPBOARD_ACCESS_NONE", + 2: "UI_CLIPBOARD_ACCESS_READ", + 3: "UI_CLIPBOARD_ACCESS_WRITE", + 4: "UI_CLIPBOARD_ACCESS_ALL", + } + UiClipboardAccess_value = map[string]int32{ + "UI_CLIPBOARD_ACCESS_UNSPECIFIED": 0, + "UI_CLIPBOARD_ACCESS_NONE": 1, + "UI_CLIPBOARD_ACCESS_READ": 2, + "UI_CLIPBOARD_ACCESS_WRITE": 3, + "UI_CLIPBOARD_ACCESS_ALL": 4, + } +) + +func (x UiClipboardAccess) Enum() *UiClipboardAccess { + p := new(UiClipboardAccess) + *p = x + return p +} + +func (x UiClipboardAccess) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (UiClipboardAccess) Descriptor() protoreflect.EnumDescriptor { + return file_sandbox_proto_enumTypes[0].Descriptor() +} + +func (UiClipboardAccess) Type() protoreflect.EnumType { + return &file_sandbox_proto_enumTypes[0] +} + +func (x UiClipboardAccess) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use UiClipboardAccess.Descriptor instead. +func (UiClipboardAccess) EnumDescriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{0} +} + // TLS handling for a network endpoint. type NetworkTlsMode int32 @@ -72,11 +133,11 @@ func (x NetworkTlsMode) String() string { } func (NetworkTlsMode) Descriptor() protoreflect.EnumDescriptor { - return file_sandbox_proto_enumTypes[0].Descriptor() + return file_sandbox_proto_enumTypes[1].Descriptor() } func (NetworkTlsMode) Type() protoreflect.EnumType { - return &file_sandbox_proto_enumTypes[0] + return &file_sandbox_proto_enumTypes[1] } func (x NetworkTlsMode) Number() protoreflect.EnumNumber { @@ -85,7 +146,7 @@ func (x NetworkTlsMode) Number() protoreflect.EnumNumber { // Deprecated: Use NetworkTlsMode.Descriptor instead. func (NetworkTlsMode) EnumDescriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{0} + return file_sandbox_proto_rawDescGZIP(), []int{1} } // Enforcement behavior for inspected network traffic. @@ -125,11 +186,11 @@ func (x NetworkEnforcementMode) String() string { } func (NetworkEnforcementMode) Descriptor() protoreflect.EnumDescriptor { - return file_sandbox_proto_enumTypes[1].Descriptor() + return file_sandbox_proto_enumTypes[2].Descriptor() } func (NetworkEnforcementMode) Type() protoreflect.EnumType { - return &file_sandbox_proto_enumTypes[1] + return &file_sandbox_proto_enumTypes[2] } func (x NetworkEnforcementMode) Number() protoreflect.EnumNumber { @@ -138,7 +199,7 @@ func (x NetworkEnforcementMode) Number() protoreflect.EnumNumber { // Deprecated: Use NetworkEnforcementMode.Descriptor instead. func (NetworkEnforcementMode) EnumDescriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{1} + return file_sandbox_proto_rawDescGZIP(), []int{2} } // Shorthand access policy for an inspected network endpoint. @@ -182,11 +243,11 @@ func (x NetworkAccessPreset) String() string { } func (NetworkAccessPreset) Descriptor() protoreflect.EnumDescriptor { - return file_sandbox_proto_enumTypes[2].Descriptor() + return file_sandbox_proto_enumTypes[3].Descriptor() } func (NetworkAccessPreset) Type() protoreflect.EnumType { - return &file_sandbox_proto_enumTypes[2] + return &file_sandbox_proto_enumTypes[3] } func (x NetworkAccessPreset) Number() protoreflect.EnumNumber { @@ -195,7 +256,7 @@ func (x NetworkAccessPreset) Number() protoreflect.EnumNumber { // Deprecated: Use NetworkAccessPreset.Descriptor instead. func (NetworkAccessPreset) EnumDescriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{2} + return file_sandbox_proto_rawDescGZIP(), []int{3} } // Scope that currently controls a setting. @@ -232,11 +293,11 @@ func (x SettingScope) String() string { } func (SettingScope) Descriptor() protoreflect.EnumDescriptor { - return file_sandbox_proto_enumTypes[3].Descriptor() + return file_sandbox_proto_enumTypes[4].Descriptor() } func (SettingScope) Type() protoreflect.EnumType { - return &file_sandbox_proto_enumTypes[3] + return &file_sandbox_proto_enumTypes[4] } func (x SettingScope) Number() protoreflect.EnumNumber { @@ -245,7 +306,7 @@ func (x SettingScope) Number() protoreflect.EnumNumber { // Deprecated: Use SettingScope.Descriptor instead. func (SettingScope) EnumDescriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{3} + return file_sandbox_proto_rawDescGZIP(), []int{4} } // Source used for the policy payload in GetSandboxConfigResponse. @@ -282,11 +343,11 @@ func (x PolicySource) String() string { } func (PolicySource) Descriptor() protoreflect.EnumDescriptor { - return file_sandbox_proto_enumTypes[4].Descriptor() + return file_sandbox_proto_enumTypes[5].Descriptor() } func (PolicySource) Type() protoreflect.EnumType { - return &file_sandbox_proto_enumTypes[4] + return &file_sandbox_proto_enumTypes[5] } func (x PolicySource) Number() protoreflect.EnumNumber { @@ -295,7 +356,7 @@ func (x PolicySource) Number() protoreflect.EnumNumber { // Deprecated: Use PolicySource.Descriptor instead. func (PolicySource) EnumDescriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{4} + return file_sandbox_proto_rawDescGZIP(), []int{5} } // Sandbox security policy configuration. @@ -315,8 +376,12 @@ type SandboxPolicy struct { // policy-local names. At most 10 configs are accepted, and at most 10 stages // can be selected per request. NetworkMiddlewares map[string]*NetworkMiddlewareConfig `protobuf:"bytes,6,rep,name=network_middlewares,json=networkMiddlewares,proto3" json:"network_middlewares,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Static, platform-neutral user-interface access policy. Within an explicit + // section, omitted capabilities deny. Omitting the section preserves the + // compute platform's existing behavior. + Ui *UiPolicy `protobuf:"bytes,7,opt,name=ui,proto3" json:"ui,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SandboxPolicy) Reset() { @@ -391,6 +456,13 @@ func (x *SandboxPolicy) GetNetworkMiddlewares() map[string]*NetworkMiddlewareCon return nil } +func (x *SandboxPolicy) GetUi() *UiPolicy { + if x != nil { + return x.Ui + } + return nil +} + // Filesystem access policy. type FilesystemPolicy struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -556,6 +628,72 @@ func (x *ProcessPolicy) GetRunAsGroup() string { return "" } +// Platform-neutral user-interface capabilities. Every omitted field in an +// explicit policy defaults to deny. Compute platforms without complete support +// reject the entire explicit policy before provisioning. +type UiPolicy struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Allow the sandbox to display graphical windows. + AllowGraphicalUi bool `protobuf:"varint,1,opt,name=allow_graphical_ui,json=allowGraphicalUi,proto3" json:"allow_graphical_ui,omitempty"` + // Directional host clipboard access. + Clipboard UiClipboardAccess `protobuf:"varint,2,opt,name=clipboard,proto3,enum=openshell.sandbox.v1.UiClipboardAccess" json:"clipboard,omitempty"` + // Allow the sandbox to synthesize keyboard or pointer input. + AllowInputInjection bool `protobuf:"varint,3,opt,name=allow_input_injection,json=allowInputInjection,proto3" json:"allow_input_injection,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UiPolicy) Reset() { + *x = UiPolicy{} + mi := &file_sandbox_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UiPolicy) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UiPolicy) ProtoMessage() {} + +func (x *UiPolicy) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UiPolicy.ProtoReflect.Descriptor instead. +func (*UiPolicy) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{4} +} + +func (x *UiPolicy) GetAllowGraphicalUi() bool { + if x != nil { + return x.AllowGraphicalUi + } + return false +} + +func (x *UiPolicy) GetClipboard() UiClipboardAccess { + if x != nil { + return x.Clipboard + } + return UiClipboardAccess_UI_CLIPBOARD_ACCESS_UNSPECIFIED +} + +func (x *UiPolicy) GetAllowInputInjection() bool { + if x != nil { + return x.AllowInputInjection + } + return false +} + // A named network access policy rule. type NetworkPolicyRule struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -571,7 +709,7 @@ type NetworkPolicyRule struct { func (x *NetworkPolicyRule) Reset() { *x = NetworkPolicyRule{} - mi := &file_sandbox_proto_msgTypes[4] + mi := &file_sandbox_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -583,7 +721,7 @@ func (x *NetworkPolicyRule) String() string { func (*NetworkPolicyRule) ProtoMessage() {} func (x *NetworkPolicyRule) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[4] + mi := &file_sandbox_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -596,7 +734,7 @@ func (x *NetworkPolicyRule) ProtoReflect() protoreflect.Message { // Deprecated: Use NetworkPolicyRule.ProtoReflect.Descriptor instead. func (*NetworkPolicyRule) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{4} + return file_sandbox_proto_rawDescGZIP(), []int{5} } func (x *NetworkPolicyRule) GetName() string { @@ -641,7 +779,7 @@ type NetworkMiddlewareConfig struct { func (x *NetworkMiddlewareConfig) Reset() { *x = NetworkMiddlewareConfig{} - mi := &file_sandbox_proto_msgTypes[5] + mi := &file_sandbox_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -653,7 +791,7 @@ func (x *NetworkMiddlewareConfig) String() string { func (*NetworkMiddlewareConfig) ProtoMessage() {} func (x *NetworkMiddlewareConfig) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[5] + mi := &file_sandbox_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -666,7 +804,7 @@ func (x *NetworkMiddlewareConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use NetworkMiddlewareConfig.ProtoReflect.Descriptor instead. func (*NetworkMiddlewareConfig) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{5} + return file_sandbox_proto_rawDescGZIP(), []int{6} } func (x *NetworkMiddlewareConfig) GetName() string { @@ -726,7 +864,7 @@ type MiddlewareEndpointSelector struct { func (x *MiddlewareEndpointSelector) Reset() { *x = MiddlewareEndpointSelector{} - mi := &file_sandbox_proto_msgTypes[6] + mi := &file_sandbox_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -738,7 +876,7 @@ func (x *MiddlewareEndpointSelector) String() string { func (*MiddlewareEndpointSelector) ProtoMessage() {} func (x *MiddlewareEndpointSelector) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[6] + mi := &file_sandbox_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -751,7 +889,7 @@ func (x *MiddlewareEndpointSelector) ProtoReflect() protoreflect.Message { // Deprecated: Use MiddlewareEndpointSelector.ProtoReflect.Descriptor instead. func (*MiddlewareEndpointSelector) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{6} + return file_sandbox_proto_rawDescGZIP(), []int{7} } func (x *MiddlewareEndpointSelector) GetInclude() []string { @@ -780,7 +918,7 @@ type NetworkCredentialBinding struct { func (x *NetworkCredentialBinding) Reset() { *x = NetworkCredentialBinding{} - mi := &file_sandbox_proto_msgTypes[7] + mi := &file_sandbox_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -792,7 +930,7 @@ func (x *NetworkCredentialBinding) String() string { func (*NetworkCredentialBinding) ProtoMessage() {} func (x *NetworkCredentialBinding) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[7] + mi := &file_sandbox_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -805,7 +943,7 @@ func (x *NetworkCredentialBinding) ProtoReflect() protoreflect.Message { // Deprecated: Use NetworkCredentialBinding.ProtoReflect.Descriptor instead. func (*NetworkCredentialBinding) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{7} + return file_sandbox_proto_rawDescGZIP(), []int{8} } func (x *NetworkCredentialBinding) GetProvider() string { @@ -841,7 +979,6 @@ type NetworkEndpoint struct { // When non-empty, the SSRF internal-IP check is replaced by an allowlist check: // - If host is also set: domain must resolve to an IP in this list. // - If host is empty: any domain is allowed as long as it resolves to an IP in this list. - // // Supports exact IPs ("10.0.5.20") and CIDR notation ("10.0.5.0/24"). // Loopback (127.0.0.0/8) and link-local (169.254.0.0/16) are always blocked // regardless of this field. @@ -917,7 +1054,7 @@ type NetworkEndpoint struct { func (x *NetworkEndpoint) Reset() { *x = NetworkEndpoint{} - mi := &file_sandbox_proto_msgTypes[8] + mi := &file_sandbox_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -929,7 +1066,7 @@ func (x *NetworkEndpoint) String() string { func (*NetworkEndpoint) ProtoMessage() {} func (x *NetworkEndpoint) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[8] + mi := &file_sandbox_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -942,7 +1079,7 @@ func (x *NetworkEndpoint) ProtoReflect() protoreflect.Message { // Deprecated: Use NetworkEndpoint.ProtoReflect.Descriptor instead. func (*NetworkEndpoint) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{8} + return file_sandbox_proto_rawDescGZIP(), []int{9} } func (x *NetworkEndpoint) GetHost() string { @@ -1168,7 +1305,7 @@ type McpOptions struct { func (x *McpOptions) Reset() { *x = McpOptions{} - mi := &file_sandbox_proto_msgTypes[9] + mi := &file_sandbox_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1180,7 +1317,7 @@ func (x *McpOptions) String() string { func (*McpOptions) ProtoMessage() {} func (x *McpOptions) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[9] + mi := &file_sandbox_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1193,7 +1330,7 @@ func (x *McpOptions) ProtoReflect() protoreflect.Message { // Deprecated: Use McpOptions.ProtoReflect.Descriptor instead. func (*McpOptions) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{9} + return file_sandbox_proto_rawDescGZIP(), []int{10} } func (x *McpOptions) GetStrictToolNames() bool { @@ -1232,7 +1369,7 @@ type GraphqlOperation struct { func (x *GraphqlOperation) Reset() { *x = GraphqlOperation{} - mi := &file_sandbox_proto_msgTypes[10] + mi := &file_sandbox_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1244,7 +1381,7 @@ func (x *GraphqlOperation) String() string { func (*GraphqlOperation) ProtoMessage() {} func (x *GraphqlOperation) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[10] + mi := &file_sandbox_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1257,7 +1394,7 @@ func (x *GraphqlOperation) ProtoReflect() protoreflect.Message { // Deprecated: Use GraphqlOperation.ProtoReflect.Descriptor instead. func (*GraphqlOperation) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{10} + return file_sandbox_proto_rawDescGZIP(), []int{11} } func (x *GraphqlOperation) GetOperationType() string { @@ -1312,7 +1449,7 @@ type L7DenyRule struct { func (x *L7DenyRule) Reset() { *x = L7DenyRule{} - mi := &file_sandbox_proto_msgTypes[11] + mi := &file_sandbox_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1324,7 +1461,7 @@ func (x *L7DenyRule) String() string { func (*L7DenyRule) ProtoMessage() {} func (x *L7DenyRule) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[11] + mi := &file_sandbox_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1337,7 +1474,7 @@ func (x *L7DenyRule) ProtoReflect() protoreflect.Message { // Deprecated: Use L7DenyRule.ProtoReflect.Descriptor instead. func (*L7DenyRule) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{11} + return file_sandbox_proto_rawDescGZIP(), []int{12} } func (x *L7DenyRule) GetMethod() string { @@ -1406,7 +1543,7 @@ type L7Rule struct { func (x *L7Rule) Reset() { *x = L7Rule{} - mi := &file_sandbox_proto_msgTypes[12] + mi := &file_sandbox_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1418,7 +1555,7 @@ func (x *L7Rule) String() string { func (*L7Rule) ProtoMessage() {} func (x *L7Rule) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[12] + mi := &file_sandbox_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1431,7 +1568,7 @@ func (x *L7Rule) ProtoReflect() protoreflect.Message { // Deprecated: Use L7Rule.ProtoReflect.Descriptor instead. func (*L7Rule) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{12} + return file_sandbox_proto_rawDescGZIP(), []int{13} } func (x *L7Rule) GetAllow() *L7Allow { @@ -1471,7 +1608,7 @@ type L7Allow struct { func (x *L7Allow) Reset() { *x = L7Allow{} - mi := &file_sandbox_proto_msgTypes[13] + mi := &file_sandbox_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1483,7 +1620,7 @@ func (x *L7Allow) String() string { func (*L7Allow) ProtoMessage() {} func (x *L7Allow) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[13] + mi := &file_sandbox_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1496,7 +1633,7 @@ func (x *L7Allow) ProtoReflect() protoreflect.Message { // Deprecated: Use L7Allow.ProtoReflect.Descriptor instead. func (*L7Allow) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{13} + return file_sandbox_proto_rawDescGZIP(), []int{14} } func (x *L7Allow) GetMethod() string { @@ -1568,7 +1705,7 @@ type L7QueryMatcher struct { func (x *L7QueryMatcher) Reset() { *x = L7QueryMatcher{} - mi := &file_sandbox_proto_msgTypes[14] + mi := &file_sandbox_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1580,7 +1717,7 @@ func (x *L7QueryMatcher) String() string { func (*L7QueryMatcher) ProtoMessage() {} func (x *L7QueryMatcher) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[14] + mi := &file_sandbox_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1593,7 +1730,7 @@ func (x *L7QueryMatcher) ProtoReflect() protoreflect.Message { // Deprecated: Use L7QueryMatcher.ProtoReflect.Descriptor instead. func (*L7QueryMatcher) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{14} + return file_sandbox_proto_rawDescGZIP(), []int{15} } func (x *L7QueryMatcher) GetGlob() string { @@ -1620,7 +1757,7 @@ type NetworkBinary struct { func (x *NetworkBinary) Reset() { *x = NetworkBinary{} - mi := &file_sandbox_proto_msgTypes[15] + mi := &file_sandbox_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1632,7 +1769,7 @@ func (x *NetworkBinary) String() string { func (*NetworkBinary) ProtoMessage() {} func (x *NetworkBinary) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[15] + mi := &file_sandbox_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1645,7 +1782,7 @@ func (x *NetworkBinary) ProtoReflect() protoreflect.Message { // Deprecated: Use NetworkBinary.ProtoReflect.Descriptor instead. func (*NetworkBinary) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{15} + return file_sandbox_proto_rawDescGZIP(), []int{16} } func (x *NetworkBinary) GetPath() string { @@ -1667,7 +1804,7 @@ type GetSandboxConfigRequest struct { func (x *GetSandboxConfigRequest) Reset() { *x = GetSandboxConfigRequest{} - mi := &file_sandbox_proto_msgTypes[16] + mi := &file_sandbox_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1679,7 +1816,7 @@ func (x *GetSandboxConfigRequest) String() string { func (*GetSandboxConfigRequest) ProtoMessage() {} func (x *GetSandboxConfigRequest) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[16] + mi := &file_sandbox_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1692,7 +1829,7 @@ func (x *GetSandboxConfigRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSandboxConfigRequest.ProtoReflect.Descriptor instead. func (*GetSandboxConfigRequest) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{16} + return file_sandbox_proto_rawDescGZIP(), []int{17} } func (x *GetSandboxConfigRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -1718,7 +1855,7 @@ type GetGatewayConfigRequest struct { func (x *GetGatewayConfigRequest) Reset() { *x = GetGatewayConfigRequest{} - mi := &file_sandbox_proto_msgTypes[17] + mi := &file_sandbox_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1730,7 +1867,7 @@ func (x *GetGatewayConfigRequest) String() string { func (*GetGatewayConfigRequest) ProtoMessage() {} func (x *GetGatewayConfigRequest) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[17] + mi := &file_sandbox_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1743,7 +1880,7 @@ func (x *GetGatewayConfigRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetGatewayConfigRequest.ProtoReflect.Descriptor instead. func (*GetGatewayConfigRequest) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{17} + return file_sandbox_proto_rawDescGZIP(), []int{18} } // Response containing gateway-global settings. @@ -1760,7 +1897,7 @@ type GetGatewayConfigResponse struct { func (x *GetGatewayConfigResponse) Reset() { *x = GetGatewayConfigResponse{} - mi := &file_sandbox_proto_msgTypes[18] + mi := &file_sandbox_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1772,7 +1909,7 @@ func (x *GetGatewayConfigResponse) String() string { func (*GetGatewayConfigResponse) ProtoMessage() {} func (x *GetGatewayConfigResponse) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[18] + mi := &file_sandbox_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1785,7 +1922,7 @@ func (x *GetGatewayConfigResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetGatewayConfigResponse.ProtoReflect.Descriptor instead. func (*GetGatewayConfigResponse) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{18} + return file_sandbox_proto_rawDescGZIP(), []int{19} } func (x *GetGatewayConfigResponse) GetSettings() map[string]*SettingValue { @@ -1818,7 +1955,7 @@ type SettingValue struct { func (x *SettingValue) Reset() { *x = SettingValue{} - mi := &file_sandbox_proto_msgTypes[19] + mi := &file_sandbox_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1830,7 +1967,7 @@ func (x *SettingValue) String() string { func (*SettingValue) ProtoMessage() {} func (x *SettingValue) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[19] + mi := &file_sandbox_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1843,7 +1980,7 @@ func (x *SettingValue) ProtoReflect() protoreflect.Message { // Deprecated: Use SettingValue.ProtoReflect.Descriptor instead. func (*SettingValue) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{19} + return file_sandbox_proto_rawDescGZIP(), []int{20} } func (x *SettingValue) GetValue() isSettingValue_Value { @@ -1928,7 +2065,7 @@ type EffectiveSetting struct { func (x *EffectiveSetting) Reset() { *x = EffectiveSetting{} - mi := &file_sandbox_proto_msgTypes[20] + mi := &file_sandbox_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1940,7 +2077,7 @@ func (x *EffectiveSetting) String() string { func (*EffectiveSetting) ProtoMessage() {} func (x *EffectiveSetting) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[20] + mi := &file_sandbox_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1953,7 +2090,7 @@ func (x *EffectiveSetting) ProtoReflect() protoreflect.Message { // Deprecated: Use EffectiveSetting.ProtoReflect.Descriptor instead. func (*EffectiveSetting) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{20} + return file_sandbox_proto_rawDescGZIP(), []int{21} } func (x *EffectiveSetting) GetValue() *SettingValue { @@ -2022,7 +2159,7 @@ type GetSandboxConfigResponse struct { func (x *GetSandboxConfigResponse) Reset() { *x = GetSandboxConfigResponse{} - mi := &file_sandbox_proto_msgTypes[21] + mi := &file_sandbox_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2034,7 +2171,7 @@ func (x *GetSandboxConfigResponse) String() string { func (*GetSandboxConfigResponse) ProtoMessage() {} func (x *GetSandboxConfigResponse) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[21] + mi := &file_sandbox_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2047,7 +2184,7 @@ func (x *GetSandboxConfigResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSandboxConfigResponse.ProtoReflect.Descriptor instead. func (*GetSandboxConfigResponse) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{21} + return file_sandbox_proto_rawDescGZIP(), []int{22} } func (x *GetSandboxConfigResponse) GetPolicy() *SandboxPolicy { @@ -2194,7 +2331,7 @@ type SupervisorMiddlewareService struct { func (x *SupervisorMiddlewareService) Reset() { *x = SupervisorMiddlewareService{} - mi := &file_sandbox_proto_msgTypes[22] + mi := &file_sandbox_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2206,7 +2343,7 @@ func (x *SupervisorMiddlewareService) String() string { func (*SupervisorMiddlewareService) ProtoMessage() {} func (x *SupervisorMiddlewareService) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[22] + mi := &file_sandbox_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2219,7 +2356,7 @@ func (x *SupervisorMiddlewareService) ProtoReflect() protoreflect.Message { // Deprecated: Use SupervisorMiddlewareService.ProtoReflect.Descriptor instead. func (*SupervisorMiddlewareService) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{22} + return file_sandbox_proto_rawDescGZIP(), []int{23} } func (x *SupervisorMiddlewareService) GetName() string { @@ -2275,7 +2412,7 @@ var File_sandbox_proto protoreflect.FileDescriptor const file_sandbox_proto_rawDesc = "" + "\n" + - "\rsandbox.proto\x12\x14openshell.sandbox.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1egoogle/protobuf/duration.proto\x1a\x0fdatamodel.proto\"\xa8\x05\n" + + "\rsandbox.proto\x12\x14openshell.sandbox.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1egoogle/protobuf/duration.proto\x1a\x0fdatamodel.proto\"\xd8\x05\n" + "\rSandboxPolicy\x12\x18\n" + "\aversion\x18\x01 \x01(\rR\aversion\x12F\n" + "\n" + @@ -2284,7 +2421,8 @@ const file_sandbox_proto_rawDesc = "" + "\blandlock\x18\x03 \x01(\v2$.openshell.sandbox.v1.LandlockPolicyR\blandlock\x12=\n" + "\aprocess\x18\x04 \x01(\v2#.openshell.sandbox.v1.ProcessPolicyR\aprocess\x12c\n" + "\x10network_policies\x18\x05 \x03(\v28.openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntryR\x0fnetworkPolicies\x12l\n" + - "\x13network_middlewares\x18\x06 \x03(\v2;.openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntryR\x12networkMiddlewares\x1ak\n" + + "\x13network_middlewares\x18\x06 \x03(\v2;.openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntryR\x12networkMiddlewares\x12.\n" + + "\x02ui\x18\a \x01(\v2\x1e.openshell.sandbox.v1.UiPolicyR\x02ui\x1ak\n" + "\x14NetworkPoliciesEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12=\n" + "\x05value\x18\x02 \x01(\v2'.openshell.sandbox.v1.NetworkPolicyRuleR\x05value:\x028\x01\x1at\n" + @@ -2301,7 +2439,11 @@ const file_sandbox_proto_rawDesc = "" + "\rProcessPolicy\x12\x1e\n" + "\vrun_as_user\x18\x01 \x01(\tR\trunAsUser\x12 \n" + "\frun_as_group\x18\x02 \x01(\tR\n" + - "runAsGroup\"\xad\x01\n" + + "runAsGroup\"\xb3\x01\n" + + "\bUiPolicy\x12,\n" + + "\x12allow_graphical_ui\x18\x01 \x01(\bR\x10allowGraphicalUi\x12E\n" + + "\tclipboard\x18\x02 \x01(\x0e2'.openshell.sandbox.v1.UiClipboardAccessR\tclipboard\x122\n" + + "\x15allow_input_injection\x18\x03 \x01(\bR\x13allowInputInjection\"\xad\x01\n" + "\x11NetworkPolicyRule\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12C\n" + "\tendpoints\x18\x02 \x03(\v2%.openshell.sandbox.v1.NetworkEndpointR\tendpoints\x12?\n" + @@ -2454,7 +2596,13 @@ const file_sandbox_proto_rawDesc = "" + "\x0frequest_timeout\x18h \x01(\v2\x19.google.protobuf.DurationR\x0erequestTimeout\x12%\n" + "\x0ftls_ca_cert_pem\x18\x05 \x01(\fR\ftlsCaCertPem\x12\x1a\n" + "\baudience\x18\x06 \x01(\tR\baudience\x128\n" + - "\x18allow_insecure_transport\x18\a \x01(\bR\x16allowInsecureTransportJ\x04\b\x04\x10\x05R\atimeout*\x97\x01\n" + + "\x18allow_insecure_transport\x18\a \x01(\bR\x16allowInsecureTransportJ\x04\b\x04\x10\x05R\atimeout*\xb0\x01\n" + + "\x11UiClipboardAccess\x12#\n" + + "\x1fUI_CLIPBOARD_ACCESS_UNSPECIFIED\x10\x00\x12\x1c\n" + + "\x18UI_CLIPBOARD_ACCESS_NONE\x10\x01\x12\x1c\n" + + "\x18UI_CLIPBOARD_ACCESS_READ\x10\x02\x12\x1d\n" + + "\x19UI_CLIPBOARD_ACCESS_WRITE\x10\x03\x12\x1b\n" + + "\x17UI_CLIPBOARD_ACCESS_ALL\x10\x04*\x97\x01\n" + "\x0eNetworkTlsMode\x12 \n" + "\x1cNETWORK_TLS_MODE_UNSPECIFIED\x10\x00\x12\x19\n" + "\x15NETWORK_TLS_MODE_SKIP\x10\x01\x12\"\n" + @@ -2490,96 +2638,100 @@ func file_sandbox_proto_rawDescGZIP() []byte { return file_sandbox_proto_rawDescData } -var file_sandbox_proto_enumTypes = make([]protoimpl.EnumInfo, 5) -var file_sandbox_proto_msgTypes = make([]protoimpl.MessageInfo, 32) +var file_sandbox_proto_enumTypes = make([]protoimpl.EnumInfo, 6) +var file_sandbox_proto_msgTypes = make([]protoimpl.MessageInfo, 33) var file_sandbox_proto_goTypes = []any{ - (NetworkTlsMode)(0), // 0: openshell.sandbox.v1.NetworkTlsMode - (NetworkEnforcementMode)(0), // 1: openshell.sandbox.v1.NetworkEnforcementMode - (NetworkAccessPreset)(0), // 2: openshell.sandbox.v1.NetworkAccessPreset - (SettingScope)(0), // 3: openshell.sandbox.v1.SettingScope - (PolicySource)(0), // 4: openshell.sandbox.v1.PolicySource - (*SandboxPolicy)(nil), // 5: openshell.sandbox.v1.SandboxPolicy - (*FilesystemPolicy)(nil), // 6: openshell.sandbox.v1.FilesystemPolicy - (*LandlockPolicy)(nil), // 7: openshell.sandbox.v1.LandlockPolicy - (*ProcessPolicy)(nil), // 8: openshell.sandbox.v1.ProcessPolicy - (*NetworkPolicyRule)(nil), // 9: openshell.sandbox.v1.NetworkPolicyRule - (*NetworkMiddlewareConfig)(nil), // 10: openshell.sandbox.v1.NetworkMiddlewareConfig - (*MiddlewareEndpointSelector)(nil), // 11: openshell.sandbox.v1.MiddlewareEndpointSelector - (*NetworkCredentialBinding)(nil), // 12: openshell.sandbox.v1.NetworkCredentialBinding - (*NetworkEndpoint)(nil), // 13: openshell.sandbox.v1.NetworkEndpoint - (*McpOptions)(nil), // 14: openshell.sandbox.v1.McpOptions - (*GraphqlOperation)(nil), // 15: openshell.sandbox.v1.GraphqlOperation - (*L7DenyRule)(nil), // 16: openshell.sandbox.v1.L7DenyRule - (*L7Rule)(nil), // 17: openshell.sandbox.v1.L7Rule - (*L7Allow)(nil), // 18: openshell.sandbox.v1.L7Allow - (*L7QueryMatcher)(nil), // 19: openshell.sandbox.v1.L7QueryMatcher - (*NetworkBinary)(nil), // 20: openshell.sandbox.v1.NetworkBinary - (*GetSandboxConfigRequest)(nil), // 21: openshell.sandbox.v1.GetSandboxConfigRequest - (*GetGatewayConfigRequest)(nil), // 22: openshell.sandbox.v1.GetGatewayConfigRequest - (*GetGatewayConfigResponse)(nil), // 23: openshell.sandbox.v1.GetGatewayConfigResponse - (*SettingValue)(nil), // 24: openshell.sandbox.v1.SettingValue - (*EffectiveSetting)(nil), // 25: openshell.sandbox.v1.EffectiveSetting - (*GetSandboxConfigResponse)(nil), // 26: openshell.sandbox.v1.GetSandboxConfigResponse - (*SupervisorMiddlewareService)(nil), // 27: openshell.sandbox.v1.SupervisorMiddlewareService - nil, // 28: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry - nil, // 29: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry - nil, // 30: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry - nil, // 31: openshell.sandbox.v1.L7DenyRule.QueryEntry - nil, // 32: openshell.sandbox.v1.L7DenyRule.ParamsEntry - nil, // 33: openshell.sandbox.v1.L7Allow.QueryEntry - nil, // 34: openshell.sandbox.v1.L7Allow.ParamsEntry - nil, // 35: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry - nil, // 36: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry - (*structpb.Struct)(nil), // 37: google.protobuf.Struct - (*datamodelv1.WorkspaceSelector)(nil), // 38: openshell.datamodel.v1.WorkspaceSelector - (*durationpb.Duration)(nil), // 39: google.protobuf.Duration + (UiClipboardAccess)(0), // 0: openshell.sandbox.v1.UiClipboardAccess + (NetworkTlsMode)(0), // 1: openshell.sandbox.v1.NetworkTlsMode + (NetworkEnforcementMode)(0), // 2: openshell.sandbox.v1.NetworkEnforcementMode + (NetworkAccessPreset)(0), // 3: openshell.sandbox.v1.NetworkAccessPreset + (SettingScope)(0), // 4: openshell.sandbox.v1.SettingScope + (PolicySource)(0), // 5: openshell.sandbox.v1.PolicySource + (*SandboxPolicy)(nil), // 6: openshell.sandbox.v1.SandboxPolicy + (*FilesystemPolicy)(nil), // 7: openshell.sandbox.v1.FilesystemPolicy + (*LandlockPolicy)(nil), // 8: openshell.sandbox.v1.LandlockPolicy + (*ProcessPolicy)(nil), // 9: openshell.sandbox.v1.ProcessPolicy + (*UiPolicy)(nil), // 10: openshell.sandbox.v1.UiPolicy + (*NetworkPolicyRule)(nil), // 11: openshell.sandbox.v1.NetworkPolicyRule + (*NetworkMiddlewareConfig)(nil), // 12: openshell.sandbox.v1.NetworkMiddlewareConfig + (*MiddlewareEndpointSelector)(nil), // 13: openshell.sandbox.v1.MiddlewareEndpointSelector + (*NetworkCredentialBinding)(nil), // 14: openshell.sandbox.v1.NetworkCredentialBinding + (*NetworkEndpoint)(nil), // 15: openshell.sandbox.v1.NetworkEndpoint + (*McpOptions)(nil), // 16: openshell.sandbox.v1.McpOptions + (*GraphqlOperation)(nil), // 17: openshell.sandbox.v1.GraphqlOperation + (*L7DenyRule)(nil), // 18: openshell.sandbox.v1.L7DenyRule + (*L7Rule)(nil), // 19: openshell.sandbox.v1.L7Rule + (*L7Allow)(nil), // 20: openshell.sandbox.v1.L7Allow + (*L7QueryMatcher)(nil), // 21: openshell.sandbox.v1.L7QueryMatcher + (*NetworkBinary)(nil), // 22: openshell.sandbox.v1.NetworkBinary + (*GetSandboxConfigRequest)(nil), // 23: openshell.sandbox.v1.GetSandboxConfigRequest + (*GetGatewayConfigRequest)(nil), // 24: openshell.sandbox.v1.GetGatewayConfigRequest + (*GetGatewayConfigResponse)(nil), // 25: openshell.sandbox.v1.GetGatewayConfigResponse + (*SettingValue)(nil), // 26: openshell.sandbox.v1.SettingValue + (*EffectiveSetting)(nil), // 27: openshell.sandbox.v1.EffectiveSetting + (*GetSandboxConfigResponse)(nil), // 28: openshell.sandbox.v1.GetSandboxConfigResponse + (*SupervisorMiddlewareService)(nil), // 29: openshell.sandbox.v1.SupervisorMiddlewareService + nil, // 30: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry + nil, // 31: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry + nil, // 32: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry + nil, // 33: openshell.sandbox.v1.L7DenyRule.QueryEntry + nil, // 34: openshell.sandbox.v1.L7DenyRule.ParamsEntry + nil, // 35: openshell.sandbox.v1.L7Allow.QueryEntry + nil, // 36: openshell.sandbox.v1.L7Allow.ParamsEntry + nil, // 37: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry + nil, // 38: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry + (*structpb.Struct)(nil), // 39: google.protobuf.Struct + (*datamodelv1.WorkspaceSelector)(nil), // 40: openshell.datamodel.v1.WorkspaceSelector + (*durationpb.Duration)(nil), // 41: google.protobuf.Duration } var file_sandbox_proto_depIdxs = []int32{ - 6, // 0: openshell.sandbox.v1.SandboxPolicy.filesystem:type_name -> openshell.sandbox.v1.FilesystemPolicy - 7, // 1: openshell.sandbox.v1.SandboxPolicy.landlock:type_name -> openshell.sandbox.v1.LandlockPolicy - 8, // 2: openshell.sandbox.v1.SandboxPolicy.process:type_name -> openshell.sandbox.v1.ProcessPolicy - 28, // 3: openshell.sandbox.v1.SandboxPolicy.network_policies:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry - 29, // 4: openshell.sandbox.v1.SandboxPolicy.network_middlewares:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry - 13, // 5: openshell.sandbox.v1.NetworkPolicyRule.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint - 20, // 6: openshell.sandbox.v1.NetworkPolicyRule.binaries:type_name -> openshell.sandbox.v1.NetworkBinary - 37, // 7: openshell.sandbox.v1.NetworkMiddlewareConfig.config:type_name -> google.protobuf.Struct - 11, // 8: openshell.sandbox.v1.NetworkMiddlewareConfig.endpoints:type_name -> openshell.sandbox.v1.MiddlewareEndpointSelector - 0, // 9: openshell.sandbox.v1.NetworkEndpoint.tls:type_name -> openshell.sandbox.v1.NetworkTlsMode - 1, // 10: openshell.sandbox.v1.NetworkEndpoint.enforcement:type_name -> openshell.sandbox.v1.NetworkEnforcementMode - 2, // 11: openshell.sandbox.v1.NetworkEndpoint.access:type_name -> openshell.sandbox.v1.NetworkAccessPreset - 17, // 12: openshell.sandbox.v1.NetworkEndpoint.rules:type_name -> openshell.sandbox.v1.L7Rule - 16, // 13: openshell.sandbox.v1.NetworkEndpoint.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule - 30, // 14: openshell.sandbox.v1.NetworkEndpoint.graphql_persisted_queries:type_name -> openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry - 14, // 15: openshell.sandbox.v1.NetworkEndpoint.mcp:type_name -> openshell.sandbox.v1.McpOptions - 12, // 16: openshell.sandbox.v1.NetworkEndpoint.credential_binding:type_name -> openshell.sandbox.v1.NetworkCredentialBinding - 31, // 17: openshell.sandbox.v1.L7DenyRule.query:type_name -> openshell.sandbox.v1.L7DenyRule.QueryEntry - 32, // 18: openshell.sandbox.v1.L7DenyRule.params:type_name -> openshell.sandbox.v1.L7DenyRule.ParamsEntry - 18, // 19: openshell.sandbox.v1.L7Rule.allow:type_name -> openshell.sandbox.v1.L7Allow - 33, // 20: openshell.sandbox.v1.L7Allow.query:type_name -> openshell.sandbox.v1.L7Allow.QueryEntry - 34, // 21: openshell.sandbox.v1.L7Allow.params:type_name -> openshell.sandbox.v1.L7Allow.ParamsEntry - 38, // 22: openshell.sandbox.v1.GetSandboxConfigRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 35, // 23: openshell.sandbox.v1.GetGatewayConfigResponse.settings:type_name -> openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry - 24, // 24: openshell.sandbox.v1.EffectiveSetting.value:type_name -> openshell.sandbox.v1.SettingValue - 3, // 25: openshell.sandbox.v1.EffectiveSetting.scope:type_name -> openshell.sandbox.v1.SettingScope - 5, // 26: openshell.sandbox.v1.GetSandboxConfigResponse.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 36, // 27: openshell.sandbox.v1.GetSandboxConfigResponse.settings:type_name -> openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry - 4, // 28: openshell.sandbox.v1.GetSandboxConfigResponse.policy_source:type_name -> openshell.sandbox.v1.PolicySource - 27, // 29: openshell.sandbox.v1.GetSandboxConfigResponse.supervisor_middleware_services:type_name -> openshell.sandbox.v1.SupervisorMiddlewareService - 39, // 30: openshell.sandbox.v1.SupervisorMiddlewareService.request_timeout:type_name -> google.protobuf.Duration - 9, // 31: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry.value:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 10, // 32: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry.value:type_name -> openshell.sandbox.v1.NetworkMiddlewareConfig - 15, // 33: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry.value:type_name -> openshell.sandbox.v1.GraphqlOperation - 19, // 34: openshell.sandbox.v1.L7DenyRule.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 19, // 35: openshell.sandbox.v1.L7DenyRule.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 19, // 36: openshell.sandbox.v1.L7Allow.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 19, // 37: openshell.sandbox.v1.L7Allow.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 24, // 38: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.SettingValue - 25, // 39: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.EffectiveSetting - 40, // [40:40] is the sub-list for method output_type - 40, // [40:40] is the sub-list for method input_type - 40, // [40:40] is the sub-list for extension type_name - 40, // [40:40] is the sub-list for extension extendee - 0, // [0:40] is the sub-list for field type_name + 7, // 0: openshell.sandbox.v1.SandboxPolicy.filesystem:type_name -> openshell.sandbox.v1.FilesystemPolicy + 8, // 1: openshell.sandbox.v1.SandboxPolicy.landlock:type_name -> openshell.sandbox.v1.LandlockPolicy + 9, // 2: openshell.sandbox.v1.SandboxPolicy.process:type_name -> openshell.sandbox.v1.ProcessPolicy + 30, // 3: openshell.sandbox.v1.SandboxPolicy.network_policies:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry + 31, // 4: openshell.sandbox.v1.SandboxPolicy.network_middlewares:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry + 10, // 5: openshell.sandbox.v1.SandboxPolicy.ui:type_name -> openshell.sandbox.v1.UiPolicy + 0, // 6: openshell.sandbox.v1.UiPolicy.clipboard:type_name -> openshell.sandbox.v1.UiClipboardAccess + 15, // 7: openshell.sandbox.v1.NetworkPolicyRule.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint + 22, // 8: openshell.sandbox.v1.NetworkPolicyRule.binaries:type_name -> openshell.sandbox.v1.NetworkBinary + 39, // 9: openshell.sandbox.v1.NetworkMiddlewareConfig.config:type_name -> google.protobuf.Struct + 13, // 10: openshell.sandbox.v1.NetworkMiddlewareConfig.endpoints:type_name -> openshell.sandbox.v1.MiddlewareEndpointSelector + 1, // 11: openshell.sandbox.v1.NetworkEndpoint.tls:type_name -> openshell.sandbox.v1.NetworkTlsMode + 2, // 12: openshell.sandbox.v1.NetworkEndpoint.enforcement:type_name -> openshell.sandbox.v1.NetworkEnforcementMode + 3, // 13: openshell.sandbox.v1.NetworkEndpoint.access:type_name -> openshell.sandbox.v1.NetworkAccessPreset + 19, // 14: openshell.sandbox.v1.NetworkEndpoint.rules:type_name -> openshell.sandbox.v1.L7Rule + 18, // 15: openshell.sandbox.v1.NetworkEndpoint.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule + 32, // 16: openshell.sandbox.v1.NetworkEndpoint.graphql_persisted_queries:type_name -> openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry + 16, // 17: openshell.sandbox.v1.NetworkEndpoint.mcp:type_name -> openshell.sandbox.v1.McpOptions + 14, // 18: openshell.sandbox.v1.NetworkEndpoint.credential_binding:type_name -> openshell.sandbox.v1.NetworkCredentialBinding + 33, // 19: openshell.sandbox.v1.L7DenyRule.query:type_name -> openshell.sandbox.v1.L7DenyRule.QueryEntry + 34, // 20: openshell.sandbox.v1.L7DenyRule.params:type_name -> openshell.sandbox.v1.L7DenyRule.ParamsEntry + 20, // 21: openshell.sandbox.v1.L7Rule.allow:type_name -> openshell.sandbox.v1.L7Allow + 35, // 22: openshell.sandbox.v1.L7Allow.query:type_name -> openshell.sandbox.v1.L7Allow.QueryEntry + 36, // 23: openshell.sandbox.v1.L7Allow.params:type_name -> openshell.sandbox.v1.L7Allow.ParamsEntry + 40, // 24: openshell.sandbox.v1.GetSandboxConfigRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 37, // 25: openshell.sandbox.v1.GetGatewayConfigResponse.settings:type_name -> openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry + 26, // 26: openshell.sandbox.v1.EffectiveSetting.value:type_name -> openshell.sandbox.v1.SettingValue + 4, // 27: openshell.sandbox.v1.EffectiveSetting.scope:type_name -> openshell.sandbox.v1.SettingScope + 6, // 28: openshell.sandbox.v1.GetSandboxConfigResponse.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 38, // 29: openshell.sandbox.v1.GetSandboxConfigResponse.settings:type_name -> openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry + 5, // 30: openshell.sandbox.v1.GetSandboxConfigResponse.policy_source:type_name -> openshell.sandbox.v1.PolicySource + 29, // 31: openshell.sandbox.v1.GetSandboxConfigResponse.supervisor_middleware_services:type_name -> openshell.sandbox.v1.SupervisorMiddlewareService + 41, // 32: openshell.sandbox.v1.SupervisorMiddlewareService.request_timeout:type_name -> google.protobuf.Duration + 11, // 33: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry.value:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 12, // 34: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry.value:type_name -> openshell.sandbox.v1.NetworkMiddlewareConfig + 17, // 35: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry.value:type_name -> openshell.sandbox.v1.GraphqlOperation + 21, // 36: openshell.sandbox.v1.L7DenyRule.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 21, // 37: openshell.sandbox.v1.L7DenyRule.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 21, // 38: openshell.sandbox.v1.L7Allow.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 21, // 39: openshell.sandbox.v1.L7Allow.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 26, // 40: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.SettingValue + 27, // 41: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.EffectiveSetting + 42, // [42:42] is the sub-list for method output_type + 42, // [42:42] is the sub-list for method input_type + 42, // [42:42] is the sub-list for extension type_name + 42, // [42:42] is the sub-list for extension extendee + 0, // [0:42] is the sub-list for field type_name } func init() { file_sandbox_proto_init() } @@ -2587,8 +2739,8 @@ func file_sandbox_proto_init() { if File_sandbox_proto != nil { return } - file_sandbox_proto_msgTypes[9].OneofWrappers = []any{} - file_sandbox_proto_msgTypes[19].OneofWrappers = []any{ + file_sandbox_proto_msgTypes[10].OneofWrappers = []any{} + file_sandbox_proto_msgTypes[20].OneofWrappers = []any{ (*SettingValue_StringValue)(nil), (*SettingValue_BoolValue)(nil), (*SettingValue_IntValue)(nil), @@ -2599,8 +2751,8 @@ func file_sandbox_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_sandbox_proto_rawDesc), len(file_sandbox_proto_rawDesc)), - NumEnums: 5, - NumMessages: 32, + NumEnums: 6, + NumMessages: 33, NumExtensions: 0, NumServices: 0, }, diff --git a/skills/generate-sandbox-policy/SKILL.md b/skills/generate-sandbox-policy/SKILL.md index b5a0f6f4b4..4e796773b8 100644 --- a/skills/generate-sandbox-policy/SKILL.md +++ b/skills/generate-sandbox-policy/SKILL.md @@ -11,7 +11,7 @@ Generate YAML sandbox network policies and network middleware configuration from This skill translates a user's plain-language policy intent into a valid sandbox policy. The amount of detail the user provides determines the granularity of the generated policy — from broad L4 or preset-based policies (just a host:port) up to fine-grained per-endpoint L7 rules (full API docs). -The output is a `network_policies` YAML block, an optional `network_middlewares` block, and optionally a full policy file that conforms to the sandbox policy schema. +The output is a `network_policies` YAML block, an optional `network_middlewares` block, an optional static `ui` block when explicitly requested, and optionally a full policy file that conforms to the sandbox policy schema. ## Step 1: Gather Inputs @@ -168,6 +168,7 @@ Key sections to reference: - **Private IP Access via `allowed_ips`** — CIDR allowlist for private IP space - **Network Middleware** - top-level middleware configs, ordering, host selection, and failure behavior - **Validation Rules** — what combinations are valid/invalid +- **UI** — static, portable capabilities and runtime support boundaries When middleware is requested, also read the published [supervisor middleware guide](https://docs.nvidia.com/openshell/latest/extensibility/supervisor-middleware.md). @@ -258,6 +259,28 @@ Use the most specific pattern that covers the intent. Prefer narrow globs over ` ## Step 5: Generate the Policy +### UI Policy + +Emit `ui` only when the user explicitly requests a graphical surface, +clipboard access, or synthetic input. Choose the narrowest capability and keep +unrequested fields omitted so they remain deny by default: + +```yaml +ui: + allow_graphical_ui: true + clipboard: read # none | read | write | all + allow_input_injection: false +``` + +Treat clipboard direction from the sandbox's perspective. Warn that UI is a +static sandbox-creation control. It is currently enforceable only by the MXC +driver's OpenShell `process_container` backend, which emits MXC's +`processcontainer` containment value and advertises complete support. MXC +`isolation_session` and non-Windows drivers advertise no support, so the gateway +rejects any explicit UI section, including `{}`, before provisioning. Omit the +section rather than emitting deny-only UI for those drivers; omission preserves +their existing behavior. + ### Output Format Generate a complete `network_policies` entry. Use this template: @@ -387,6 +410,7 @@ Before presenting the policy to the user, verify correctness **and** flag breadt - [ ] Any required WebSocket control advertises `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`, and the user understands that V1 does not inspect binary messages - [ ] Any required response control advertises `HTTP_RESPONSE/PRE_RETURN` - [ ] Endpoints contributed by a credentialed provider are not L4-only or `tls: skip` unless `allow_uninspected_credentials: true` explicitly records the exception +- [ ] An explicit `ui` section targets a configured driver/backend that advertises complete UI-policy support ### Schema Warnings (log-only, but should be fixed) @@ -449,7 +473,7 @@ The policy needs to go somewhere. Determine which mode applies: 1. **Read the existing file** to understand current state: - What policies already exist under `network_policies` - - What the `filesystem_policy`, `landlock`, and `process` sections look like + - What the `filesystem_policy`, `landlock`, `process`, and `ui` sections look like - Whether the file uses compact (`{ host: ..., port: ... }`) or expanded YAML style 2. **Check for conflicts**: @@ -468,7 +492,7 @@ The policy needs to go somewhere. Determine which mode applies: - **Modifying an existing policy**: Edit the specific policy in place — add/remove endpoints, change access presets, update rules, add binaries, etc. A rule authorizes every binary it lists to reach every endpoint and port it lists, so adding one binary grants it all of that rule's endpoints, and adding one endpoint grants it to all of that rule's binaries. State the resulting pairs to the user before writing them. When the user wants a binary to reach only part of a rule's endpoints, put that binary and those endpoints in a separate rule instead of extending the existing one. An empty `binaries` list means any binary, so leaving it off widens the rule to every process. - **Removing a policy**: Delete the policy block if the user asks. -4. **Preserve everything else**: Do not modify `filesystem_policy`, `landlock`, `process`, or other policies unless the user explicitly asks. +4. **Preserve everything else**: Do not modify `filesystem_policy`, `landlock`, `process`, `ui`, or other policies unless the user explicitly asks. ### Mode B: Create a New Policy File diff --git a/skills/generate-sandbox-policy/examples.md b/skills/generate-sandbox-policy/examples.md index 17b7ca4646..96a703afea 100644 --- a/skills/generate-sandbox-policy/examples.md +++ b/skills/generate-sandbox-policy/examples.md @@ -747,7 +747,7 @@ An exact IP is treated as `/32` — only that specific address is permitted. - { path: /usr/bin/curl } ``` -The agent inserts the new entry after the last existing policy in the `network_policies` block. All other sections (`filesystem_policy`, `landlock`, `process`) are untouched. +The agent inserts the new entry after the last existing policy in the `network_policies` block. All other sections (`filesystem_policy`, `landlock`, `process`, `ui`) are untouched. --- diff --git a/skills/openshell-cli/SKILL.md b/skills/openshell-cli/SKILL.md index 3977018a01..28e0cafe83 100644 --- a/skills/openshell-cli/SKILL.md +++ b/skills/openshell-cli/SKILL.md @@ -465,6 +465,11 @@ openshell logs my-sandbox --since 5m openshell sandbox delete my-sandbox openshell sandbox delete sandbox-1 sandbox-2 sandbox-3 # Multiple at once openshell sandbox delete --all + +# Fail closed if the observed sandbox was replaced or changed +openshell sandbox delete my-sandbox \ + --expected-id \ + --expected-resource-version ``` `deletion accepted` means cleanup is still pending. Inspect the sandbox until @@ -472,6 +477,10 @@ it disappears before assuming completion. An already-absent sandbox succeeds; missing workspaces and authorization failures remain errors. Do not blindly retry by name if another process might have recreated that name. +Identity preconditions are valid only for one named sandbox, and a resource +version requires the immutable ID. A mismatch returns `ABORTED` before +OpenShell mutates gateway state or calls the compute driver. + ### Stop and start sandboxes Use stop to halt compute while retaining the sandbox and its persistent @@ -496,7 +505,7 @@ the operation that removes retained state. This is the most important multi-step workflow. It enables a tight feedback cycle where sandbox policy is refined based on observed activity. -**Key concept**: Policies have static fields (immutable after activation: `filesystem_policy`, `landlock`, `process`) and two dynamic fields: `network_policies` and `network_middlewares`. Both dynamic fields can be updated without recreating the sandbox when the selected compute driver supports live policy updates. Drivers without the standard supervisor fetch revisions through the sandbox configuration API and report whether they loaded them. +**Key concept**: Policies have static fields (immutable after activation: `filesystem_policy`, `landlock`, `process`, `ui`) and two dynamic fields: `network_policies` and `network_middlewares`. Both dynamic fields can be updated without recreating the sandbox when the selected compute driver supports live policy updates. Drivers without the standard supervisor fetch revisions through the sandbox configuration API and report whether they loaded them. If startup reports `ConfigurationInvalid`, inspect `openshell sandbox get` and repair the complete policy or provider set through the gateway. The workload @@ -512,6 +521,11 @@ wait timeout is separate from this gateway deadline. Follow the published [policy repair guidance](https://docs.nvidia.com/openshell/latest/how-it-works/policies/overview) and confirm current replacement/detach syntax with installed CLI help. +UI capabilities require a driver/backend advertising complete UI-policy support. +MXC's `process_container` backend enforces them at provisioning; changing UI +capabilities after activation requires recreating the sandbox. Backends without +UI-policy support reject an explicit UI section before provisioning. + An endpoint with omitted `protocol` retains explicit-proxy behavior. Explicit `protocol: tcp` requests policy DNS and transparent TCP and currently requires the Docker or Podman runtime; unsupported runtimes reject the policy before starting the @@ -583,7 +597,7 @@ Edit `current-policy.yaml` to allow the blocked actions. **For policy content au - Binary matching patterns - Ordered `network_middlewares`, host selection, HTTP request/response and WebSocket bindings, and `fail_open` or `fail_closed` behavior -`network_policies` and `network_middlewares` can be modified at runtime when the selected compute driver supports live policy updates. Use `--wait` to verify that the active runtime loaded the revision; do not infer enforcement from the gateway accepting the update. If `filesystem_policy`, `landlock`, or `process` need changes, the sandbox must be recreated. Built-in middleware such as `openshell/regex` needs no gateway registration. An operator-run middleware must already be registered under `[[openshell.supervisor.middleware]]`; changing that static registration requires a gateway restart. +`network_policies` and `network_middlewares` can be modified at runtime when the selected compute driver supports live policy updates. Use `--wait` to verify that the active runtime loaded the revision; do not infer enforcement from the gateway accepting the update. MXC rejects live policy replacement and merge updates; delete and recreate an MXC sandbox instead. If `filesystem_policy`, `landlock`, `process`, or `ui` need changes, the sandbox must be recreated. Built-in middleware such as `openshell/regex` needs no gateway registration. An operator-run middleware must already be registered under `[[openshell.supervisor.middleware]]`; changing that static registration requires a gateway restart. Middleware can inspect HTTP requests, HTTP responses, or client WebSocket text messages when the implementation advertises the matching binding. The built-in diff --git a/tasks/scripts/windows-msvc.ps1 b/tasks/scripts/windows-msvc.ps1 index 0bc373af0f..5b0480edf6 100644 --- a/tasks/scripts/windows-msvc.ps1 +++ b/tasks/scripts/windows-msvc.ps1 @@ -6,7 +6,7 @@ [CmdletBinding()] param( [Parameter(Mandatory = $true, Position = 0)] - [ValidateSet("check", "lint", "build", "test", "test-precommit", "test-unsupported", "artifacts", "ci")] + [ValidateSet("check", "lint", "build", "test", "test-precommit", "test-unsupported", "test-mxc-real", "artifacts", "ci")] [string] $Action, [Parameter(Position = 1)] @@ -50,7 +50,7 @@ if (-not [int]::TryParse($BuildJobsValue, [ref] $WindowsBuildJobs) -or $WindowsB } $WindowsCargoMutex = [System.Threading.Mutex]::new($false, "Local\OpenShellWindowsMsvcCargo") -$UnsupportedDriverPackageExcludes = "--exclude openshell-driver-docker --exclude openshell-driver-kubernetes --exclude openshell-driver-kubernetes-secrets --exclude openshell-driver-podman --exclude openshell-driver-vault --exclude openshell-driver-vm --exclude openshell-sandbox --exclude openshell-vfio" +$UnsupportedDriverPackageExcludes = "--exclude openshell-driver-docker --exclude openshell-driver-kubernetes --exclude openshell-driver-kubernetes-secrets --exclude openshell-driver-podman --exclude openshell-driver-vault --exclude openshell-driver-vm --exclude openshell-vfio" $WindowsClippyPackageExcludes = $UnsupportedDriverPackageExcludes $WindowsClippyLintArgs = "-D warnings -A dead-code -A unused-imports -A clippy::unused-async" $PrebuiltZ3WorkspaceFeatures = "--features openshell-prover/prebuilt-z3" @@ -512,7 +512,7 @@ function Invoke-Lint([string] $RustTarget) { function Invoke-Build([string] $RustTarget) { Invoke-VsCargo ` -RustTarget $RustTarget ` - -CargoArgs "cargo build --release --target $RustTarget --bin openshell-gateway --bin openshell $Z3WorkspaceFeatures" ` + -CargoArgs "cargo build --release --target $RustTarget --bin openshell-gateway --bin openshell --bin openshell-supervisor --bin openshell-windows-sandbox $Z3WorkspaceFeatures" ` -LogName "build-$RustTarget-release.log" } @@ -542,7 +542,7 @@ function Invoke-UnsupportedContractTests([string] $RustTarget) { foreach ($test in $tests) { Invoke-VsCargo ` -RustTarget $RustTarget ` - -CargoArgs "cargo test -p openshell-gateway --target $RustTarget $test $Z3ServerFeatures" ` + -CargoArgs "cargo test -p openshell-gateway --target $RustTarget $test $Z3WorkspaceFeatures" ` -LogName "test-$RustTarget-unsupported-$test.log" } @@ -556,6 +556,14 @@ function Invoke-UnsupportedContractTests([string] $RustTarget) { } } +function Invoke-MxcRealTests([string] $RustTarget) { + Assert-NativeTestTarget $RustTarget + Invoke-VsCargo ` + -RustTarget $RustTarget ` + -CargoArgs "cargo test -p openshell-driver-mxc --test wxc_exec_real --target $RustTarget -- --ignored --test-threads=1 --nocapture" ` + -LogName "test-$RustTarget-mxc-real.log" +} + function Get-Sha256([string] $Path) { $stream = [System.IO.File]::OpenRead($Path) try { @@ -573,7 +581,7 @@ function Get-Sha256([string] $Path) { function Show-Artifacts([string[]] $RustTargets) { $rows = @() foreach ($rustTarget in $RustTargets) { - foreach ($binary in @("openshell-gateway.exe", "openshell.exe")) { + foreach ($binary in @("openshell-gateway.exe", "openshell.exe", "openshell-supervisor.exe", "openshell-windows-sandbox.exe")) { $path = Join-Path $TargetDir "$rustTarget\release\$binary" if (-not (Test-Path $path)) { continue @@ -600,13 +608,13 @@ if ($Action -eq "ci" -and (Get-HostArch) -ne "amd64") { } $targets = Get-SelectedTargets $Target -if ($Action -in @("test", "test-precommit", "test-unsupported")) { +if ($Action -in @("test", "test-precommit", "test-unsupported", "test-mxc-real")) { foreach ($rustTarget in $targets) { Assert-NativeTestTarget $rustTarget } } -if ($Action -in @("check", "lint", "build", "test", "test-precommit", "test-unsupported", "ci")) { +if ($Action -in @("check", "lint", "build", "test", "test-precommit", "test-unsupported", "test-mxc-real", "ci")) { $z3Features = Configure-Z3 $Z3WorkspaceFeatures = $z3Features.WorkspaceFeatures $Z3ServerFeatures = $z3Features.ServerFeatures @@ -648,6 +656,11 @@ switch ($Action) { Invoke-UnsupportedContractTests $rustTarget } } + "test-mxc-real" { + foreach ($rustTarget in $targets) { + Invoke-MxcRealTests $rustTarget + } + } "artifacts" { Show-Artifacts $targets } diff --git a/tasks/windows.toml b/tasks/windows.toml index 708ba2c248..6fe15c5f4b 100644 --- a/tasks/windows.toml +++ b/tasks/windows.toml @@ -76,16 +76,16 @@ run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 ci all" ["windows:test:mxc-real:x64"] -description = "Run real-wxc-exec Tier-2 integration tests (skip-safe: tests print SKIP when binary/backend absent)" +description = "Run native x64 real-wxc-exec Tier-2 integration tests (skip-safe: tests print SKIP when binary/backend absent)" run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" -run_windows = "cargo test -p openshell-driver-mxc --test wxc_exec_real --target x86_64-pc-windows-msvc -- --ignored --test-threads=1" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-mxc-real x86_64-pc-windows-msvc" -["windows:e2e:mxc"] -description = "Run MXC Tier-3 e2e scenario runner against real wxc-exec (probe-gated; skip-safe on hosts without the binary)" +["windows:test:mxc-real:arm64"] +description = "Run native ARM64 real-wxc-exec Tier-2 integration tests (skip-safe: tests print SKIP when binary/backend absent)" run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" -run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-mxc-real aarch64-pc-windows-msvc" -["windows:e2e:mxc:mock"] -description = "Run MXC Tier-3 e2e scenario runner in mock/wiring-only mode (no real wxc-exec required)" +["windows:e2e:mxc"] +description = "Run real MXC Tier-3 e2e; missing runtime coverage exits non-zero (no mocks)" run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" -run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 -Mock" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1"