diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index 9b6229d96b..f8e672bb4f 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-sandbox.exe` (MXC ProcessContainer boundary) It intentionally does not make Windows a Docker, Kubernetes, Podman, or VM runtime host. @@ -45,8 +47,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-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. @@ -59,12 +61,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 @@ -157,6 +156,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: @@ -165,12 +174,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 { @@ -240,12 +251,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-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. | @@ -315,6 +328,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 1045c15950..7ab02aa242 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 @@ -76,7 +76,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 @@ -111,18 +111,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-supervisor ---exclude openshell-supervisor-process --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. ## Common Errors diff --git a/AGENTS.md b/AGENTS.md index 3ac4eedf15..a18fd2243a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,7 +63,7 @@ These pipelines connect skills into end-to-end workflows. Individual skill files | `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-sandbox` inside MXC | | `crates/openshell-prover/` | Policy prover | Policy verification and proof generation | | `crates/openshell-server-macros/` | Server macros | Compile-time helpers for gateway RPC authorization | | `crates/openshell-supervisor-middleware/` | Middleware runtime | Generic middleware registry, remote service integration, and chain execution | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 29e90769f8..283a286656 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 6d5c8afd55..e098e9cccd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4193,13 +4193,16 @@ dependencies = [ name = "openshell-driver-mxc" version = "0.0.0" dependencies = [ + "anyhow", "base64", "futures", "noyalib", "openshell-core", + "openshell-isolation-interface", "openshell-ocsf", "openshell-policy", - "openshell-supervisor-network", + "openshell-sandbox-backend", + "rand 0.9.4", "rustls", "serde", "serde_json", @@ -4394,6 +4397,8 @@ dependencies = [ "openshell-core", "rustix 1.1.4", "serde", + "serde_json", + "sha2 0.10.9", "tokio", ] @@ -4532,6 +4537,8 @@ dependencies = [ "tonic", "tracing", "tracing-subscriber", + "url", + "windows", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ebc0485a97..4381ff7875 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/architecture/compute-runtimes.md b/architecture/compute-runtimes.md index a2bb25c312..a2fbeaefec 100644 --- a/architecture/compute-runtimes.md +++ b/architecture/compute-runtimes.md @@ -44,8 +44,8 @@ launches and monitors the policy-constrained workload itself. `compute_driver.proto` is the supported gateway/driver extension boundary. At initialization the gateway snapshots the driver's identity, version, -default image, gateway-lifecycle preference, and -`driver_reports_runtime_readiness` from `GetCapabilities`. The gateway includes +default image, gateway-lifecycle preference, runtime-readiness ownership, and +complete UI-policy enforcement support from `GetCapabilities`. The gateway includes the canonical `SandboxPolicy` in `DriverSandboxSpec.policy` for validation and creation. Drivers that enforce policy outside the standard supervisor fetch later revisions through `GetSandboxConfig` and acknowledge them through @@ -118,6 +118,18 @@ The capability RPC reports driver identity, version, and the default sandbox image used by the gateway. GPU availability stays driver-local and is validated when a sandbox create request asks for GPU resources. +UI policy is capability-negotiated at the configured driver-instance boundary. +`supports_ui_policy = true` means the driver completely enforces every field in +the current portable `SandboxPolicy.ui` contract; partial implementations must +report false. When `ui` is explicitly present, including as `{}`, the gateway +rejects create before the driver validation RPC or provisioning unless this +capability is true. An absent section bypasses this gate and preserves the +runtime's existing behavior. The startup snapshot is also exposed through +gateway info so clients can discover the selected runtime's support. UI cannot +be supplied by a gateway-global policy because it is applied at startup. When a +global dynamic policy is active, effective-policy reads retain the UI block from +the sandbox's creation policy. + The gateway records driver identity and version from the startup capability response. Elevated gateway info reports that initialized driver snapshot instead of re-querying drivers on each request. @@ -155,6 +167,23 @@ on a server-only API. ## Stop and Start Lifecycle +On Windows, the MXC driver creates the same RFC 0012 runtime pairing as the VM +backend: `openshell-supervisor --role=isolation-backend` runs on the trusted +host and `openshell-sandbox` runs inside the ProcessContainer. Their +generation-scoped TLS transport and sandbox JWT carry lifecycle, exec, +forwarding, provider refresh, and retained process I/O. The driver only +provisions and monitors the pair; it does not define a second control or relay +protocol. + +MXC denies direct Internet access and allows only the loopback route required +for the authenticated Sandbox Protocol and explicit proxy. Proxy-aware +workloads receive a per-generation authenticated proxy URL and public CA trust +material. The host supervisor applies OpenShell network policy and provider +injection. The loopback exception does not isolate unrelated host services, and +the current Windows explicit-proxy path attributes descendant traffic to the +admitted main workload binary. See the MXC driver README for these enforcement +limits. + The gateway persists lifecycle intent before mutating compute: ```text diff --git a/architecture/gateway.md b/architecture/gateway.md index 62eceed3eb..6f44a71fe4 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -383,6 +383,12 @@ Public RPC contracts and durable protobuf formats have separate ownership. The ` `ReportEndpointStatus` is a sandbox-authenticated public gateway RPC. Its request, response, and `EndpointObservation` messages belong only to the public closure. `EndpointStatus` and `EndpointResult` also belong to the durable closure because `Sandbox.status.endpoint_statuses` persists them. The repeated status field uses a new wire tag; stored sandboxes without it decode with an empty endpoint list and retain their lifecycle fields. A fixed payload encoded with the earlier sandbox schema verifies that no database rewrite is required. +`DeleteSandboxRequest` adds optional identity and resource-version preconditions +at tags 4 and 5; the workspace selector retains tag 3. Omitted preconditions +preserve existing deletion behavior. Matching clients and servers are required +when relying on these checks: an older server can ignore unknown fields. These +request-only additions do not change any durable storage payload. + The removed `NetworkBinary.harness` field remains reserved by number and name, so protobuf implementations cannot reuse its wire slot or source identifier. The durable-policy compatibility decoder reads the former boolean before Prost @@ -700,9 +706,11 @@ leave an ambiguous final dynamic-token state or a deleted custom profile that is still referenced by a sandbox. Policy and runtime settings are delivered together through the effective sandbox -config path. A gateway-global policy can override sandbox-scoped policy. The -sandbox supervisor polls for config revisions and hot-reloads dynamic policy -when the policy engine accepts the update. +config path. A gateway-global policy can override sandbox-scoped dynamic policy. +Startup-only UI remains anchored to each sandbox's creation policy, and global +policy writes containing UI are rejected. The sandbox supervisor polls for +config revisions and hot-reloads dynamic policy when the policy engine accepts +the update. External supervisor middleware registration is operator-owned configuration under `[[openshell.supervisor.middleware]]`. At startup the gateway connects to diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 429a12d0d0..234c570d44 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -62,20 +62,34 @@ replacement from granting authority. ## Startup Flow 1. The driver resolves the immutable workload identity, installs the outer - network fence, and starts `openshell-sandbox` with one-use bootstrap state. + network fence, validates its native evidence, and starts `openshell-sandbox` + with one-use bootstrap state. Docker inspects container networking, + Kubernetes verifies its NetworkPolicy, and VM drivers inspect the guest + device model; those native schemas remain in their driver crates. 2. The sandbox consumes and unlinks bootstrap material, proves the admitted runtime posture, and listens on the protected driver channel. It does not run untrusted code yet. 3. `openshell-supervisor` loads policy and runtime settings from the gateway, attaches to the sandbox, and verifies the driver's generation and evidence. 4. The sandbox installs its seccomp notification broker and Landlock baseline, - then reports measured confirmation. The supervisor must accept that evidence -before it sends the launch permit. + validates its mechanism-specific audit evidence, and reports backend-neutral + enforcement properties. The supervisor must accept those properties and + their immutable session and resource binding before it sends the launch + permit. Other isolation backends may establish the same properties with + different mechanisms and retain their detailed evidence in backend-owned + audit data. 5. The sandbox starts the canonical process through its single workload launcher. The supervisor starts SSH and registers its gateway session. 6. Exec, signaling, PTY, DNS, TCP, and loopback-forwarding operations cross the authenticated channel for the lifetime of the sandbox generation. +The shared isolation contract receives only the driver's normalized outer-fence +guarantees: egress is default-deny, there is no unmanaged egress path, the +evidence is bound to the sandbox generation, revocation has been verified, and +controller loss fails closed. A digest commits those guarantees to the native +driver evidence without teaching the shared contract about container networks, +Kubernetes objects, VM devices, or accelerator resources. + When the admitted main process exits, its status and retained terminal output remain available. The confirmed sandbox and supervisor-owned access plane continue to serve policy-authorized exec and loopback forwarding until explicit stop or @@ -90,6 +104,16 @@ signal status, whether or not an output attachment is open or the main process has exited. Waiting never holds the exec registry lock, so other operations can still signal or attach to the process. +## Deletion Authority + +The public delete API resolves a sandbox name to its immutable metadata ID and +holds that ID's lifecycle lock through the pre-mutation check. Callers that +already observed a sandbox may also supply its expected ID and resource +version. OpenShell revalidates those preconditions under the lifecycle and +gateway-global locks, then returns `ABORTED` without changing durable state or +calling the compute driver if either value drifted. An unguarded delete retains +the interactive CLI's existing name-based behavior. + ## Isolation Layers OpenShell uses overlapping controls rather than a single sandbox primitive: diff --git a/architecture/security-policy.md b/architecture/security-policy.md index 4b9dd61334..8d4ab0c72b 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -8,17 +8,28 @@ per-request egress decisions. For the field-by-field YAML reference, use [Policy Schema Reference](../docs/reference/policy-schema.mdx). +## Windows MXC Static Enforcement + +On native Windows, the MXC driver cannot rely on Linux Landlock or setuid. It +maps portable static filesystem and UI controls from `SandboxPolicy` into MXC +configuration. UI controls are available only with the MXC +`process_container` backend; all omitted UI fields retain deny-by-default +values, and other compute runtimes reject an explicit UI policy. MXC ignores +clipboard and input-injection fields when graphical UI is disabled, so the +mapper rejects those otherwise-unenforceable combinations. + ## Policy Areas | Area | Enforcement | |---|---| | Filesystem | Landlock restricts read-only and read-write paths. | | Process | The supervisor launches the agent as an unprivileged user with reduced capabilities. | +| UI | Within an explicit UI section, omitted display, clipboard, and input-injection fields deny. The MXC driver's OpenShell `process_container` backend (MXC containment `processcontainer`) can selectively enable them. Other configured backends reject the entire explicit section before provisioning. | | Network | The proxy evaluates destination, port, calling binary, and optional L7 rules. | | Provider access | Attached provider profiles contribute endpoint and binary rules; credentials remain bound to profile-authorized endpoints. | | Runtime settings | Typed settings are delivered with policy and can be global or sandbox scoped. | -Filesystem and process policy are startup-time controls. Network policy is +Filesystem, process, and UI policy are startup-time controls. Network policy is dynamic and can be hot-reloaded when the new policy validates successfully. ### Authored policy boundary @@ -40,6 +51,19 @@ before any consumer-specific projection runs. There is no permissive parsing profile: unsupported policy fields always invalidate the document. Middleware `config`, query and persisted-query names, and recursive MCP parameter names are open user-data maps rather than schema extensions. +The UI schema names portable capabilities rather than Windows primitives: +graphical output, directional clipboard access, and synthetic input. The +configured compute driver advertises whether it completely enforces this +contract. Any explicit section, including `{}`, is rejected before driver +validation or provisioning when that capability is false. The MXC +`process_container` mapper translates the fields to MXC's top-level `ui` object +under its `processcontainer` containment value and treats omitted fields inside +the section as deny. That object is common to MXC's 0.8 stable and 0.9 +development schemas. Both schema lines reject it for `isolation_session`, so +that backend advertises no support and the mapper also rejects it in depth. +Linux, macOS, and other non-MXC paths advertise no support: explicit UI policy +fails closed, while an absent section leaves their runtime behavior unchanged. + Before applying Landlock, the supervisor enriches baseline filesystem paths that the runtime needs. Missing baseline paths are skipped so one absent runtime path does not weaken the whole ruleset. When GPU devices are present, GPU baseline @@ -254,8 +278,11 @@ generation, and whether the previous policy is active. Static controls, such as filesystem allowlists and process identity, require a new sandbox because they are applied before the child process starts. -Gateway-global policy can override sandbox-scoped policy. Use it sparingly -because it changes the effective access model for every sandbox on the gateway. +Gateway-global policy can override sandbox-scoped dynamic policy. Use it +sparingly because it changes the effective access model for every sandbox on +the gateway. A global policy cannot contain `ui`; effective-policy reads retain +the per-sandbox UI contract recorded at creation so a global update cannot +misrepresent startup enforcement. ## Policy Advisor diff --git a/architecture/windows-msvc-build.md b/architecture/windows-msvc-build.md index 9093a76a52..5dd607dba9 100644 --- a/architecture/windows-msvc-build.md +++ b/architecture/windows-msvc-build.md @@ -11,8 +11,9 @@ driver. It does not make Windows a Docker, Kubernetes, Podman, or VM runtime hos - Preserve gateway configuration parsing for all existing compute driver names. - Build and test the in-process MXC driver on supported Windows hosts. - Use the ordinary in-process compute-driver composition path; MXC receives the - canonical sandbox policy through `DriverSandboxSpec` and advertises that it - reports runtime readiness. + canonical sandbox policy through `DriverSandboxSpec`, then starts the standard + host supervisor and in-ProcessContainer sandbox boundary. Supervisor session + readiness remains authoritative. - Return clear unsupported errors when a Windows gateway is configured to use Docker, Kubernetes, Podman, or VM. - Keep dedicated `windows:*` validation tasks while allowing the repository-wide `pre-commit` task to delegate compiler-bearing Rust checks to the native @@ -49,9 +50,9 @@ domain sockets. Their libraries remain in the gateway dependency graph, so the gateway's credential-driver configuration and in-process behavior still compile on Windows. -The standalone sandbox and supervisor runtimes are Unix-only and are excluded -as top-level Windows workspace targets. The MXC driver links only the -cross-platform supervisor network library needed by its host egress proxy. +The sandbox, supervisor, and supervisor-process crates compile on Windows and +form the RFC 0012 MXC runtime pair. The release lane builds both runtime +binaries with the gateway and CLI. | Driver | Windows build behavior | Runtime behavior | |---|---|---| @@ -59,7 +60,7 @@ cross-platform supervisor network library needed by its host egress proxy. | Kubernetes | Driver crate excluded; gateway registration stub retained. | Gateway construction returns unsupported. | | Podman | Driver crate excluded; gateway registration stub retained. | Gateway construction returns unsupported. | | VM | Driver crate excluded; gateway registration stub retained. | Gateway construction returns unsupported. | -| MXC | Driver links into the native gateway and runs in Windows validation. | `process_container` is default-deny; grant-only `isolation_session` requires explicit configuration. | +| MXC | Driver, supervisor, and sandbox compile in the native Windows lane. | `process_container` supplies the default-deny outer fence and authenticated RFC 0012 runtime pair; `isolation_session` is rejected. | This keeps Windows behavior explicit without carrying runtime dependencies or creating misleading Windows driver artifacts. @@ -88,8 +89,8 @@ Windows validation is exposed through `tasks/windows.toml`: | `windows:check:arm64` | Check the ARM64 MSVC gateway/CLI build graph. | | `windows:lint:x64` | Run Clippy over the Windows-supported workspace for x64 MSVC. | | `windows:lint:arm64` | Run Clippy over the Windows-supported workspace for ARM64 MSVC. | -| `windows:build:x64` | Build release x64 `openshell-gateway.exe` and `openshell.exe`. | -| `windows:build:arm64` | Build release ARM64 `openshell-gateway.exe` and `openshell.exe`. | +| `windows:build:x64` | Build release x64 `openshell-gateway.exe`, `openshell.exe`, `openshell-supervisor.exe`, and `openshell-sandbox.exe`. | +| `windows:build:arm64` | Build the same four release binaries for ARM64. | | `windows:test:x64` | Run native x64 workspace tests with the nextest CI profile and server test support, while excluding unsupported Windows packages as top-level test targets. | | `windows:test:arm64` | Run the same suite natively on ARM64. | | `windows:test:unsupported:x64` | Run focused gateway-composition tests for unsupported driver contracts. | @@ -199,7 +200,7 @@ native rather than emulated coverage. A successful Windows build report should include: - x64 and ARM64 `cargo check` status. -- x64 and ARM64 release build status for `openshell-gateway.exe` and `openshell.exe`. +- x64 and ARM64 release build status for `openshell-gateway.exe`, `openshell.exe`, `openshell-supervisor.exe`, and `openshell-sandbox.exe`. - x64 test summary. - Native ARM64 test summary when validation runs on an ARM64 host. - Focused unsupported-driver contract test status. diff --git a/crates/openshell-cli/src/commands/common.rs b/crates/openshell-cli/src/commands/common.rs index 0f12c3df4e..29afe306af 100644 --- a/crates/openshell-cli/src/commands/common.rs +++ b/crates/openshell-cli/src/commands/common.rs @@ -878,6 +878,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 0e1fe92426..6073ac3a4b 100644 --- a/crates/openshell-cli/src/commands/gateway.rs +++ b/crates/openshell-cli/src/commands/gateway.rs @@ -45,6 +45,7 @@ struct ComputeDriverInfoView { struct ComputeDriverCapabilitiesView { driver_name: String, driver_version: String, + supports_ui_policy: bool, } /// Show gateway status. @@ -392,6 +393,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, }, } }) @@ -446,6 +448,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" + } + ); } } @@ -464,6 +475,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::>(), @@ -1822,6 +1834,7 @@ mod tests { capabilities: ComputeDriverCapabilitiesView { driver_name: "podman".to_string(), driver_version: "0.0.75".to_string(), + supports_ui_policy: false, }, }], }; @@ -1840,6 +1853,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/completers.rs b/crates/openshell-cli/src/completers.rs index c5fec20634..a0ab9a9b5a 100644 --- a/crates/openshell-cli/src/completers.rs +++ b/crates/openshell-cli/src/completers.rs @@ -217,6 +217,7 @@ mod tests { with_vars( [ ("XDG_CONFIG_HOME", Some(tmp.as_str())), + ("OPENSHELL_SYSTEM_GATEWAY_DIR", Some(tmp.as_str())), ("OPENSHELL_GATEWAY", None::<&str>), ], f, diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index fdec4ce7e5..5f9d3b2935 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1272,7 +1272,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 community sandbox name (e.g., `ollama`), a rootfs @@ -1396,6 +1396,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, @@ -1477,8 +1484,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. @@ -3067,6 +3082,7 @@ async fn run_async() -> Result<()> { no_auto_providers, labels, envs, + env_from, no_credential_warnings, approval_mode, output, @@ -3104,7 +3120,14 @@ 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 sandbox environment key '{key}' supplied through --env and --env-from" + )); + } + } run::warn_credential_env_vars(&env_map, no_credential_warnings); // Parse --upload specs into [(local_path, sandbox_path, git_ignore)]. @@ -3261,11 +3284,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, @@ -4805,6 +4835,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 ffd0fa2206..decfff016d 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, @@ -407,7 +407,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}" @@ -3247,14 +3249,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 { @@ -3307,6 +3330,8 @@ pub async fn sandbox_delete( allow_missing: true, name: name.clone(), workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), + expected_sandbox_id: expected_id.unwrap_or_default().to_string(), + expected_resource_version: expected_resource_version.unwrap_or_default(), }) .await { @@ -6115,7 +6140,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, @@ -6332,6 +6357,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 5bf1e0e1c5..665549e4c6 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -60,6 +60,7 @@ fn selected_workspace( #[derive(Clone, Default)] struct SandboxState { deleted_names: Arc>>>, + delete_requests: Arc>>, create_requests: Arc>>, fail_delete_sandbox_message: Arc>>, vm_error_after_started: Arc, @@ -332,6 +333,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() @@ -1390,6 +1396,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() } @@ -1487,6 +1497,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", @@ -1508,6 +1520,79 @@ async fn sandbox_delete_continues_after_entry_failure() { ); } +#[tokio::test] +async fn sandbox_delete_forwards_identity_preconditions() { + let server = run_server().await; + let tls = test_tls(&server); + + run::sandbox_delete( + &server.endpoint, + &["guarded-sandbox".to_string()], + false, + Some("sb-123"), + Some(17), + "default", + &tls, + "openshell", + ) + .await + .expect("identity-guarded delete should succeed"); + + let requests = delete_requests(&server).await; + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].name, "guarded-sandbox"); + assert_eq!(requests[0].expected_sandbox_id, "sb-123"); + assert_eq!(requests[0].expected_resource_version, 17); +} + +#[tokio::test] +async fn sandbox_delete_rejects_preconditions_for_multiple_names_before_rpc() { + let server = run_server().await; + let tls = test_tls(&server); + + let error = run::sandbox_delete( + &server.endpoint, + &["sandbox-a".to_string(), "sandbox-b".to_string()], + false, + Some("sb-123"), + None, + "default", + &tls, + "openshell", + ) + .await + .expect_err("identity preconditions must be single-sandbox only"); + + assert!( + error + .to_string() + .contains("require exactly one sandbox name") + ); + assert!(delete_requests(&server).await.is_empty()); +} + +#[tokio::test] +async fn sandbox_delete_rejects_resource_version_without_immutable_identity() { + let server = run_server().await; + let tls = test_tls(&server); + + let error = run::sandbox_delete( + &server.endpoint, + &["guarded-sandbox".to_string()], + false, + None, + Some(17), + "default", + &tls, + "openshell", + ) + .await + .expect_err("resource version without immutable ID must fail closed"); + + assert!(error.to_string().contains("requires --expected-id")); + assert!(delete_requests(&server).await.is_empty()); +} + #[tokio::test] async fn sandbox_create_keeps_command_sessions_by_default() { let server = run_server().await; @@ -2820,6 +2905,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_")) { @@ -2838,6 +2933,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") @@ -2856,6 +2952,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/policy.rs b/crates/openshell-core/src/policy.rs index af474325c5..8bf2828698 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. The MXC + // driver consumes the typed proto directly on Windows. // 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 4d893fa362..03bfd0ec7d 100644 --- a/crates/openshell-core/src/provider_credentials.rs +++ b/crates/openshell-core/src/provider_credentials.rs @@ -373,6 +373,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 5e50a37ad7..75bd01c542 100644 --- a/crates/openshell-driver-docker/src/isolation.rs +++ b/crates/openshell-driver-docker/src/isolation.rs @@ -11,12 +11,39 @@ use std::collections::{BTreeMap, HashMap}; use std::net::IpAddr; use std::path::PathBuf; -use openshell_isolation_interface::contract::{DriverFenceEvidence, ResolvedWorkloadIdentity}; +use openshell_isolation_interface::contract::{ + BackendError, OuterFenceGuarantees, ResolvedWorkloadIdentity, +}; use openshell_sandbox_backend::GPU_RESOURCE_CLAIM; use openshell_sandbox_backend::boundary_protocol::{ BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, }; +use serde::Serialize; + +#[derive(Serialize)] +struct DockerOuterFenceEvidence<'a> { + container_id: &'a str, + network_mode: &'static str, + unexpected_networks: &'a [String], +} + +impl DockerOuterFenceEvidence<'_> { + fn project(&self, generation: &str) -> Result { + if self.container_id.is_empty() + || self.network_mode != "none" + || !self.unexpected_networks.is_empty() + { + return Err(BackendError::Descriptor( + "Docker outer fence evidence is incomplete".to_string(), + )); + } + let encoded = serde_json::to_vec(self).map_err(|error| { + BackendError::Descriptor(format!("encode Docker outer fence evidence: {error}")) + })?; + OuterFenceGuarantees::confirmed(generation, &encoded) + } +} /// Driver-owned inputs that bind one Docker container to one boundary. pub struct DockerBoundarySpec { @@ -48,8 +75,7 @@ pub struct DockerBoundaryProvisioning { impl DockerBoundarySpec { /// Produce both sides of the common protocol from the same immutable /// Docker coordinates so attach cannot bind a different container. - #[must_use] - pub fn provision(self) -> DockerBoundaryProvisioning { + pub fn provision(self) -> Result { let mut resource_claims = BTreeMap::from([ ("docker.container_id".to_string(), self.container_id), ("docker.image_identity".to_string(), self.image_identity), @@ -57,12 +83,14 @@ impl DockerBoundarySpec { if self.gpu_requested { resource_claims.insert(GPU_RESOURCE_CLAIM.to_string(), "true".to_string()); } - let driver_fence = DriverFenceEvidence::Docker { - container_id: resource_claims["docker.container_id"].clone(), - network_mode: "none".to_string(), - unexpected_networks: Vec::new(), - }; - DockerBoundaryProvisioning { + let unexpected_networks = Vec::new(); + let outer_fence = DockerOuterFenceEvidence { + container_id: &resource_claims["docker.container_id"], + network_mode: "none", + unexpected_networks: &unexpected_networks, + } + .project(&self.generation)?; + Ok(DockerBoundaryProvisioning { boundary_config: BoundaryConfig { boundary_id: self.boundary_id.clone(), generation: self.generation.clone(), @@ -78,7 +106,8 @@ impl DockerBoundarySpec { resource_claims: resource_claims.clone(), resource_claim_files: BTreeMap::new(), workload_identity: self.workload_identity.clone(), - driver_fence: driver_fence.clone(), + outer_fence: outer_fence.clone(), + direct_proxy_url: None, child_env: self.child_env, }, runtime_descriptor: SandboxRuntimeDescriptor { @@ -91,10 +120,11 @@ impl DockerBoundarySpec { }, tls: self.supervisor_tls, host_gateway_ip: self.host_gateway_ip, + direct_proxy: None, resource_claims, - driver_fence, + outer_fence, }, - } + }) } } @@ -143,7 +173,8 @@ mod tests { .unwrap(), child_env: HashMap::new(), } - .provision(); + .provision() + .unwrap(); assert_eq!( provisioned.boundary_config.resource_claims, @@ -158,14 +189,14 @@ mod tests { "true" ); assert_eq!( - provisioned.boundary_config.driver_fence, - provisioned.runtime_descriptor.driver_fence + provisioned.boundary_config.outer_fence, + provisioned.runtime_descriptor.outer_fence ); assert!( provisioned .runtime_descriptor - .driver_fence - .validate() + .outer_fence + .validate("generation-1") .is_ok() ); } diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index 1bdfac927f..b0b398832e 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -1033,6 +1033,7 @@ impl DockerComputeDriver { }), rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_ui_policy: false, } } @@ -4425,7 +4426,8 @@ async fn prepare_docker_boundary_files( workload_identity: workload_identity.clone(), child_env: docker_child_environment(sandbox), } - .provision(); + .provision() + .map_err(|error| Status::failed_precondition(error.to_string()))?; let boundary_config = provisioning .boundary_config .encode() diff --git a/crates/openshell-driver-kubernetes/src/driver.rs b/crates/openshell-driver-kubernetes/src/driver.rs index c9e2682983..6330b1e73f 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -763,6 +763,7 @@ impl KubernetesComputeDriver { }), rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_ui_policy: false, }) } @@ -2370,7 +2371,8 @@ impl KubernetesComputeDriver { workload_identity, child_env, } - .provision(); + .provision() + .map_err(|error| KubernetesDriverError::Message(error.to_string()))?; let descriptor = provisioned .runtime_descriptor .backend_descriptor() @@ -2608,7 +2610,8 @@ impl KubernetesComputeDriver { workload_identity, child_env, } - .provision(); + .provision() + .map_err(|error| KubernetesDriverError::Message(error.to_string()))?; let descriptor = provisioned .runtime_descriptor .backend_descriptor() diff --git a/crates/openshell-driver-kubernetes/src/isolation.rs b/crates/openshell-driver-kubernetes/src/isolation.rs index bb9ae8ff66..dbf89d2db6 100644 --- a/crates/openshell-driver-kubernetes/src/isolation.rs +++ b/crates/openshell-driver-kubernetes/src/isolation.rs @@ -20,11 +20,42 @@ use k8s_openapi::api::networking::v1::{ use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector; use k8s_openapi::apimachinery::pkg::util::intstr::IntOrString; use kube::core::ObjectMeta; -use openshell_isolation_interface::contract::{DriverFenceEvidence, ResolvedWorkloadIdentity}; +use openshell_isolation_interface::contract::{ + BackendError, OuterFenceGuarantees, ResolvedWorkloadIdentity, +}; use openshell_sandbox_backend::boundary_protocol::{ BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, }; +use serde::Serialize; + +#[derive(Serialize)] +struct KubernetesOuterFenceEvidence<'a> { + network_policy_uid: &'a str, + network_policy_resource_version: &'a str, + ingress_isolated: bool, + egress_isolated: bool, + egress_rule_count: u32, +} + +impl KubernetesOuterFenceEvidence<'_> { + fn project(&self, generation: &str) -> Result { + if self.network_policy_uid.is_empty() + || self.network_policy_resource_version.is_empty() + || !self.ingress_isolated + || !self.egress_isolated + || self.egress_rule_count != 0 + { + return Err(BackendError::Descriptor( + "Kubernetes outer fence evidence is incomplete".to_string(), + )); + } + let encoded = serde_json::to_vec(self).map_err(|error| { + BackendError::Descriptor(format!("encode Kubernetes outer fence evidence: {error}")) + })?; + OuterFenceGuarantees::confirmed(generation, &encoded) + } +} /// Isolation backend implemented by the `OpenShell` sandbox runtime. pub const BACKEND_NAME: &str = openshell_sandbox_backend::BACKEND_NAME; @@ -181,8 +212,7 @@ pub struct KubernetesSandboxRuntimeBoundaryProvisioning { impl KubernetesSandboxRuntimeBoundarySpec { /// Produce both sides of the common protocol from one observed Kubernetes /// resource set so a stale or recreated object cannot be attached. - #[must_use] - pub fn provision(self) -> KubernetesSandboxRuntimeBoundaryProvisioning { + pub fn provision(self) -> Result { let resource_claims = BTreeMap::from([ ("kubernetes.namespace_uid".to_string(), self.namespace_uid), ( @@ -206,15 +236,16 @@ impl KubernetesSandboxRuntimeBoundarySpec { self.egress_policy_resource_version, ), ]); - let driver_fence = DriverFenceEvidence::Kubernetes { - network_policy_uid: resource_claims["kubernetes.egress_policy_uid"].clone(), - network_policy_resource_version: - resource_claims["kubernetes.egress_policy_resource_version"].clone(), + let outer_fence = KubernetesOuterFenceEvidence { + network_policy_uid: &resource_claims["kubernetes.egress_policy_uid"], + network_policy_resource_version: &resource_claims + ["kubernetes.egress_policy_resource_version"], ingress_isolated: true, egress_isolated: true, egress_rule_count: 0, - }; - KubernetesSandboxRuntimeBoundaryProvisioning { + } + .project(&self.generation)?; + Ok(KubernetesSandboxRuntimeBoundaryProvisioning { boundary_config: BoundaryConfig { boundary_id: self.boundary_id.clone(), generation: self.generation.clone(), @@ -233,7 +264,8 @@ impl KubernetesSandboxRuntimeBoundarySpec { self.workload_pod_uid_path, )]), workload_identity: self.workload_identity.clone(), - driver_fence: driver_fence.clone(), + outer_fence: outer_fence.clone(), + direct_proxy_url: None, child_env: self.child_env, }, runtime_descriptor: SandboxRuntimeDescriptor { @@ -247,10 +279,11 @@ impl KubernetesSandboxRuntimeBoundarySpec { }, tls: self.supervisor_tls, host_gateway_ip: self.host_gateway_ip, + direct_proxy: None, resource_claims, - driver_fence, + outer_fence, }, - } + }) } } @@ -303,7 +336,7 @@ mod tests { #[test] fn provisioning_binds_identical_kubernetes_resource_claims() { - let provisioned = spec().provision(); + let provisioned = spec().provision().unwrap(); assert_eq!( provisioned.boundary_config.resource_claims, @@ -318,21 +351,21 @@ mod tests { "1945" ); assert_eq!( - provisioned.boundary_config.driver_fence, - provisioned.runtime_descriptor.driver_fence + provisioned.boundary_config.outer_fence, + provisioned.runtime_descriptor.outer_fence ); assert!( provisioned .runtime_descriptor - .driver_fence - .validate() + .outer_fence + .validate("generation-1") .is_ok() ); } #[test] fn provisioning_uses_one_shared_tcp_protocol_across_pods() { - let provisioned = spec().provision(); + let provisioned = spec().provision().unwrap(); assert_eq!( provisioned.boundary_config.listener, diff --git a/crates/openshell-driver-mxc/Cargo.toml b/crates/openshell-driver-mxc/Cargo.toml index 5907e92a84..9d359e4953 100644 --- a/crates/openshell-driver-mxc/Cargo.toml +++ b/crates/openshell-driver-mxc/Cargo.toml @@ -15,6 +15,8 @@ name = "openshell_driver_mxc" [dependencies] openshell-core = { path = "../openshell-core", default-features = false } +openshell-isolation-interface = { path = "../openshell-isolation-interface" } +openshell-sandbox-backend = { path = "../openshell-sandbox-backend" } # OCSF builders + emit target used by the Windows ETW audit consumer. OS-agnostic # crate (no windows deps), so safe to depend on from all targets; only the # windows-gated `etw_consumer` module actually uses it. @@ -26,16 +28,17 @@ 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" } 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 9c235b85c7..af6f401514 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -1,194 +1,114 @@ # openshell-driver-mxc -OpenShell compute driver backed by **Microsoft MXC** (`wxc-exec`) on Windows. - -## Design - -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]`) +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-sandbox (ProcessContainer) + | + v +workload +``` -Gateway configuration contains only host runtime settings: +The driver provisions and monitors the two runtime processes. It does not own a +second forwarding protocol. Process lifecycle, exec, provider refresh, dynamic +forwarding, retained output, and network policy flow through the ordinary +supervisor session and authenticated Sandbox Protocol. + +## Enforcement boundaries + +- MXC supplies the default-deny filesystem fence, AppContainer token, UI + policy, and loopback-only network fence. +- `openshell-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-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-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. -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`, +`guest_tls_cert`, and `guest_tls_key` bundle. The gateway injects those paths +into the host supervisor; driver-owned copies of these fields are rejected. + +## Capabilities + +| 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 | -**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). +## Validation -## Deferred work +Run the Windows build lane on a native Windows MSVC host: + +```powershell +mise run windows:check:x64 +mise run windows:lint:x64 +mise run windows:build:x64 +mise run windows:test:mxc-real:x64 +``` -- **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. 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..f8c1368e24 100644 --- a/crates/openshell-driver-mxc/examples/mxc-gateway.toml +++ b/crates/openshell-driver-mxc/examples/mxc-gateway.toml @@ -12,18 +12,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-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..0cefca119b 100644 --- a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 +++ b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 @@ -146,6 +146,7 @@ $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 @@ -515,6 +516,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" @@ -547,7 +561,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)" @@ -563,7 +578,7 @@ try { try { $createResult = Invoke-NativeCaptured $cli @( "sandbox", "create", "--name", $sandboxName, - "--policy", [string]$sc.PolicyFile, + "--policy", [string]$policyUsed, "--driver-config-json", $driverConfig, "--no-tty" ) diff --git a/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 index 591c38bf24..b7747cf664 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-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 708aa3ae90..a5af4f3b26 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,63 +54,72 @@ 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)] pub struct MxcComputeConfig { - /// 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`. 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(PathBuf::from) + .unwrap_or_else(std::env::temp_dir) + .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(), - backend: MxcBackend::default(), + supervisor_binary_path: sibling_binary("openshell-supervisor.exe"), + sandbox_binary_path: sibling_binary("openshell-sandbox.exe"), + state_dir, + grpc_endpoint: String::new(), + backend: MxcBackend::ProcessContainer, 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`. +#[derive(Debug, Clone)] +struct GatewayConnection { + endpoint: String, + tls: Option<(PathBuf, PathBuf, PathBuf)>, + tls_server_name: Option, +} + #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] struct MxcSandboxConfig { @@ -114,8 +128,6 @@ struct MxcSandboxConfig { cwd: String, } -// ── Registry entry ──────────────────────────────────────────────────────────── - #[derive(Debug, Clone, PartialEq, Eq)] pub enum PhaseState { Starting, @@ -126,331 +138,84 @@ 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() - ))); - } - 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); - } - } - } - 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); + .field("sandbox_binary_path", &self.config.sandbox_binary_path) + .finish_non_exhaustive() } } impl MxcComputeBackend { pub fn new(config: MxcComputeConfig) -> Self { + let endpoint = config.grpc_endpoint.clone(); + Self::new_with_gateway(config, endpoint, None, None) + } + + pub fn new_with_gateway( + config: MxcComputeConfig, + endpoint: String, + tls: Option<(PathBuf, PathBuf, PathBuf)>, + tls_server_name: Option, + ) -> Self { let invoker = WxcExecInvoker::new(&config.wxc_exec_path, config.debug); let (watch_tx, _) = broadcast::channel(256); - - // 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()) { - 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()) + .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(config); - backend.invoker = WxcExecInvoker::mocked(&backend.config.wxc_exec_path); - backend - } - pub fn capabilities(&self) -> GetCapabilitiesResponse { GetCapabilitiesResponse { driver_name: DRIVER_NAME.to_string(), @@ -458,34 +223,70 @@ impl MxcComputeBackend { 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, + 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> { + 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", + )); } - sandbox_config(sandbox)?; + 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", + )); + } + launch_authentication(sandbox)? + .validate() + .map_err(|error| tonic::Status::failed_precondition(error.to_string()))?; + 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(()) } @@ -493,143 +294,100 @@ 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::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)?; 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; + let generation = uuid::Uuid::new_v4().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() @@ -639,51 +397,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(shutdown) = shutdown { + let _ = shutdown.send(()); } - 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; - } - + 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(); @@ -692,12 +423,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); @@ -709,1075 +441,730 @@ 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(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}")) - })?; + if let Some(shutdown) = shutdown { + let _ = shutdown.send(()); } - - 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, - }, - ) - .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}"), - )); + 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; } +} - // 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. +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(), + }, + ) + .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), + } + .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; - } - } - } - 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; - } - } - } + let network = MxcNetwork { + default_policy: "block".to_string(), + proxy: Some(proxy_addr), + allow_local_network: context.config.pc_allow_local_network, }; - 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, - }, - false, - ); - let (cancel_tx, cancel_rx) = watch::channel(false); + 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() { - // 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; - }; - - // 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" - ); - } - } - } - - entry.sandbox = ready_sandbox.clone(); + 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; - 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, - ))); } - let _ = watch_tx.send(sandbox_event(ready_sandbox)); + 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), + } } -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 - } - }; - - // 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() - { - idx.retire_launch(&sandbox_id, pid); +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_sandbox_backend::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, cert, key)) = &context.gateway.tls { + command + .env(openshell_core::sandbox_env::TLS_CA, ca) + .env(openshell_core::sandbox_env::TLS_CERT, cert) + .env(openshell_core::sandbox_env::TLS_KEY, key); + } + 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}")) +} - let Some(status) = status else { - return; - }; +enum RuntimePairExit { + Shutdown, + Boundary(std::process::ExitStatus), + Supervisor(std::process::ExitStatus), + Wait(String), +} - 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; - } - drop(registry); - let _ = watch_tx.send(sandbox_event(done)); +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 } - 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}")); - } - drop(registry); - let _ = watch_tx.send(sandbox_event(failed)); + 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, + ) } - Err(error) => { - warn!(sandbox = %sandbox.name, error = %error, "MXC agent exec wait error"); + 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( - registry: &Arc>>, - watch_tx: &Arc>, - sandbox: &DriverSandbox, - sandbox_id: &str, - message: &str, -) { - warn!(sandbox = %sandbox.name, error = %message, "MXC lifecycle failed"); + +async fn set_failed(context: &LifecycleContext, message: &str) { + warn!(sandbox = %context.sandbox.name, %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, - }, + &context.sandbox, + &condition("Ready", "False", "RuntimeFailed", message), false, ); - let mut reg = registry.lock().await; - if let Some(entry) = reg.get_mut(sandbox_id) { - entry.host_proxy = None; - entry.sandbox = failed.clone(); + let mut registry = context.registry.lock().await; + if let Some(entry) = registry.get_mut(&context.sandbox.id) + && !matches!(entry.phase_state, PhaseState::Stopped) + { 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(), + )); } - drop(reg); - let _ = watch_tx.send(sandbox_event(failed)); } -// ── Helpers ─────────────────────────────────────────────────────────────────── - -fn make_sandbox_with_condition( - base: &DriverSandbox, - condition: &DriverCondition, - deleting: bool, -) -> DriverSandbox { - DriverSandbox { - id: base.id.clone(), - name: base.name.clone(), - namespace: base.namespace.clone(), - workspace: base.workspace.clone(), - spec: base.spec.clone(), - status: Some(DriverSandboxStatus { - sandbox_name: base.name.clone(), - instance_id: String::new(), - agent_fd: String::new(), - sandbox_fd: String::new(), - conditions: vec![condition.clone()], - deleting, - ..Default::default() - }), +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) } -// ── 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. -#[cfg(test)] -mod lifecycle_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; - - fn driver_sandbox(id: &str) -> DriverSandbox { - driver_sandbox_with_command(id, "", vec!["cmd".into(), "/c".into(), "exit 0".into()]) - } +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 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, - }) else { - unreachable!(); - }; - DriverSandbox { - id: id.to_string(), - name: id.to_string(), - namespace: String::new(), - workspace: String::new(), - spec: Some(DriverSandboxSpec { - sandbox_token: "test-token".into(), - template: Some(DriverSandboxTemplate { - driver_config: Some( - openshell_core::proto_struct::json_object_to_struct(driver_config).unwrap(), - ), - ..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() +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 +} - fn with_policy(mut sandbox: DriverSandbox, policy: SandboxPolicy) -> DriverSandbox { - sandbox.spec.as_mut().unwrap().policy = Some(policy); - sandbox +fn trusted_sandbox_environment(minimal: bool) -> Vec { + if minimal { + return Vec::new(); } + ["SYSTEMROOT", "WINDIR", "PATH", "COMSPEC", "LOCALAPPDATA"] + .into_iter() + .filter_map(|key| { + std::env::var(key) + .ok() + .map(|value| format!("{key}={value}")) + }) + .collect() +} + +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 ready_condition(sb: &DriverSandbox) -> Option { - sb.status - .as_ref()? - .conditions - .iter() - .find(|c| c.r#type == "Ready") - .cloned() +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:?}")) +} - /// 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, +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'_')) { - 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 + Ok(value) + } else { + Err(tonic::Status::invalid_argument( + "sandbox ID is not safe for MXC state paths", + )) } +} - #[test] - fn mxc_config_defaults_to_default_deny_process_container() { - let config = MxcComputeConfig::default(); - assert_eq!(config.backend, MxcBackend::ProcessContainer); - assert!(!config.egress_proxy); - assert!(config.egress_proxy_addr.is_empty()); +fn push_unique_path(paths: &mut Vec, path: String) { + if !paths + .iter() + .any(|existing| existing.eq_ignore_ascii_case(&path)) + { + paths.push(path); } +} - #[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 cleanup_runtime_directory(path: &Path) { + if let Err(error) = std::fs::remove_dir_all(path) + && error.kind() != io::ErrorKind::NotFound + { + warn!(path = %path.display(), %error, "failed to remove MXC runtime state"); } +} - #[test] - fn sandbox_environment_inherits_host_with_spec_precedence() { - let mut sandbox = driver_sandbox("sb-env"); - let spec = sandbox.spec.as_mut().unwrap(); - spec.template - .as_mut() - .unwrap() - .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) - })); +/// 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()); } - - #[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") - ); - 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}"))); + 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(()) +} - #[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]); +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" + ))), } +} - #[test] - fn windows_command_line_preserves_argument_boundaries() { - assert_eq!( - encode_windows_command_line(&[ - r"C:\Program Files\Agent\agent.exe".into(), - "hello world".into(), - String::new(), - ]), - r#""C:\Program Files\Agent\agent.exe" "hello world" """# - ); - assert_eq!( - quote_windows_argument(r#"say "hello""#), - r#""say \"hello\"""# - ); - assert_eq!( - quote_windows_argument("trailing slash\\ "), - r#""trailing slash\ ""# - ); +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}"); + } + }); } - #[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(MxcComputeConfig::default()); - - 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; + 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}"); } - 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(MxcComputeConfig::default()); - - 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" - ); +fn encode_windows_command_line(args: &[String]) -> String { + args.iter() + .map(|argument| quote_windows_argument(argument)) + .collect::>() + .join(" ") +} - 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; +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); } - tokio::time::sleep(Duration::from_millis(100)).await; } - assert!( - found, - "in-policy write should materialize under processContainer" - ); } + quoted.push_str(&"\\".repeat(backslashes * 2)); + quoted.push('"'); + quoted +} - #[tokio::test] - async fn split_path_provisions_with_proxy_redirect() { - use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule}; +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, + } +} - 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(), +fn make_sandbox_with_condition( + base: &DriverSandbox, + condition: &DriverCondition, + deleting: bool, +) -> DriverSandbox { + DriverSandbox { + id: base.id.clone(), + name: base.name.clone(), + namespace: base.namespace.clone(), + workspace: base.workspace.clone(), + spec: base.spec.clone(), + status: Some(DriverSandboxStatus { + sandbox_name: base.name.clone(), + conditions: vec![condition.clone()], + deleting, ..Default::default() - }; - 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(MxcComputeConfig::default()); - - // 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"); +fn sandbox_event(sandbox: DriverSandbox) -> WatchSandboxesEvent { + WatchSandboxesEvent { + payload: Some(watch_sandboxes_event::Payload::Sandbox( + WatchSandboxesSandboxEvent { + sandbox: Some(sandbox), + }, + )), } +} - #[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(MxcComputeConfig::default()); - 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") - }) - .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"); +fn deleted_event(sandbox_id: String) -> WatchSandboxesEvent { + WatchSandboxesEvent { + payload: Some(watch_sandboxes_event::Payload::Deleted( + WatchSandboxesDeletedEvent { sandbox_id }, + )), } +} - #[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(MxcComputeConfig::default()); - - let mut policy = fs_policy(&[&share]); - policy.network_policies.insert( - "api".into(), - NetworkPolicyRule { - name: "api".into(), - endpoints: vec![NetworkEndpoint { - host: "example.com".into(), +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() - }], - 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(), - ..Default::default() - }; - let backend = MxcComputeBackend::new_mocked(config); +#[cfg(test)] +mod tests { + use super::*; - 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); + #[test] + fn capabilities_delegate_readiness_to_supervisor() { + let backend = MxcComputeBackend::new(MxcComputeConfig::default()); + assert!(!backend.capabilities().driver_reports_runtime_readiness); + } - 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()); + #[test] + fn command_line_quotes_spaces() { + assert_eq!( + encode_windows_command_line(&[ + "C:\\Program Files\\OpenShell\\openshell-sandbox.exe".to_string(), + "--bootstrap".to_string(), + ]), + "\"C:\\Program Files\\OpenShell\\openshell-sandbox.exe\" --bootstrap" + ); } } diff --git a/crates/openshell-driver-mxc/src/isolation.rs b/crates/openshell-driver-mxc/src/isolation.rs new file mode 100644 index 0000000000..99606ba0a3 --- /dev/null +++ b/crates/openshell-driver-mxc/src/isolation.rs @@ -0,0 +1,141 @@ +// 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, OuterFenceGuarantees, + ResolvedWorkloadIdentity, +}; +use openshell_sandbox_backend::boundary_protocol::{ + BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, + SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, +}; +use serde::Serialize; + +#[derive(Serialize)] +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(crate) 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(crate) 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}")) + })?; + let outer_fence = OuterFenceGuarantees::confirmed(&self.generation, &evidence)?; + let direct_proxy = DirectProxyConfiguration { + bind_addr: self.proxy_addr, + authorization: self.proxy_authorization, + binary_identity: BinaryIdentity { + binary_path: self.workload_binary, + 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..a6eb56b8ba 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-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..ffdba79df1 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`; it simulates AppContainer +/// filesystem enforcement for the one-shot ProcessContainer launch. 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,226 +266,7 @@ 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. + /// Mock enforcement for 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 @@ -561,10 +302,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 +315,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 +385,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 +418,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 +453,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..f0376dedf5 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 mapped = mapper.map(Some(&policy), &ctx).unwrap(); + let trimmed = mapped.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 4bfcc316a8..7f53cf32b3 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..c7382afece 100644 --- a/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs +++ b/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs @@ -210,12 +210,15 @@ 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()); + 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()); let errors: Vec<_> = result .loss .iter() @@ -295,29 +298,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 +399,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 +423,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 +436,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 +458,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 +471,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 a4ed71117a..3d2b8647f8 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 ────────────────────── @@ -930,6 +936,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!( @@ -1023,13 +1030,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 ───────────────────────────────────────────────────────────── @@ -1046,11 +1181,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", ]; @@ -1121,6 +1259,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 a3bbdc7d07..48f9f182b2 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -36,7 +36,7 @@ use openshell_core::proto::{ FilesystemPolicy, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, SandboxPolicy, }; use openshell_driver_mxc::{MxcComputeBackend, MxcComputeConfig}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::Command; use std::time::Duration; @@ -68,7 +68,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"); @@ -81,10 +81,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") @@ -97,6 +106,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 @@ -133,6 +168,103 @@ 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 base = serde_json::json!({ + "phase": "provision", + "containment": "isolation_session", + "network": { + "defaultPolicy": "allow", + "allowLocalNetwork": true, + }, + }); + let (code, stdout, stderr) = dry_run_with_args(&wxc, &base, &["--experimental"]); + 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, &["--experimental"]); + 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"] @@ -272,7 +404,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 — @@ -321,6 +454,13 @@ 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()); let (code, stdout, stderr) = dry_run(&wxc, &mxc_config); assert_eq!( @@ -762,8 +902,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(config); diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index 62142a3157..566f77fdf2 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -547,6 +547,7 @@ impl PodmanComputeDriver { }), rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_ui_policy: false, }) } diff --git a/crates/openshell-driver-podman/src/isolation.rs b/crates/openshell-driver-podman/src/isolation.rs index b616fa6474..29aa08a7ad 100644 --- a/crates/openshell-driver-podman/src/isolation.rs +++ b/crates/openshell-driver-podman/src/isolation.rs @@ -10,7 +10,7 @@ use std::path::PathBuf; use openshell_core::ComputeDriverError; use openshell_core::proto::compute::v1::DriverSandbox; -use openshell_isolation_interface::contract::{DriverFenceEvidence, ResolvedWorkloadIdentity}; +use openshell_isolation_interface::contract::{OuterFenceGuarantees, ResolvedWorkloadIdentity}; use openshell_sandbox_backend::boundary_protocol::{ BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, @@ -27,6 +27,26 @@ pub const AUTH_BUNDLE_PATH: &str = "/.openshell/supervisor/auth.json"; pub const RESTART_METADATA_PATH: &str = "/.openshell/supervisor/restart-metadata.json"; const SOCKET_PATH: &str = "/.openshell/channel/sandbox/control.sock"; +#[derive(Serialize)] +struct PodmanOuterFenceEvidence<'a> { + container_id: &'a str, + network_mode: &'static str, + unexpected_networks: &'a [String], +} + +impl PodmanOuterFenceEvidence<'_> { + fn project(&self, generation: &str) -> Result { + if self.container_id.is_empty() + || self.network_mode != "none" + || !self.unexpected_networks.is_empty() + { + return Err(invalid("Podman outer fence evidence is incomplete")); + } + let encoded = serde_json::to_vec(self).map_err(invalid)?; + OuterFenceGuarantees::confirmed(generation, &encoded).map_err(invalid) + } +} + pub fn supervisor_name(id: &str) -> String { format!("openshell-supervisor-{id}") } @@ -159,15 +179,17 @@ pub fn bootstrap_archives( identity.resource_digest.clone(), ), ]); - let driver_fence = DriverFenceEvidence::Podman { - container_id: container_id.into(), - network_mode: "none".into(), - unexpected_networks: Vec::new(), - }; let runtime_generation = launch_authentication .supervisor .runtime_generation .to_string(); + let unexpected_networks = Vec::new(); + let outer_fence = PodmanOuterFenceEvidence { + container_id, + network_mode: "none", + unexpected_networks: &unexpected_networks, + } + .project(&runtime_generation)?; let verification_keys = launch_authentication .verification_keys .iter() @@ -198,7 +220,8 @@ pub fn bootstrap_archives( resource_claims: resource_claims.clone(), resource_claim_files: BTreeMap::new(), workload_identity: identity.clone(), - driver_fence: driver_fence.clone(), + outer_fence: outer_fence.clone(), + direct_proxy_url: None, child_env: child_env.clone(), }; let runtime_descriptor = SandboxRuntimeDescriptor { @@ -213,9 +236,10 @@ pub fn bootstrap_archives( trust_anchor_pem: tls.trust_anchor_pem, }, host_gateway_ip: None, + direct_proxy: None, resource_claims, workload_identity: identity.clone(), - driver_fence, + outer_fence, }; // Libpod resolves the requested upload destination once for a stopped // container. Archive entries must be relative to the selected named volume, @@ -440,9 +464,12 @@ mod tests { .unwrap(); assert_eq!(config.boundary_id, runtime_descriptor.boundary_id); assert_eq!(config.session_id, runtime_descriptor.session_id); - assert_eq!(config.driver_fence, runtime_descriptor.driver_fence); + assert_eq!(config.outer_fence, runtime_descriptor.outer_fence); assert_eq!(config.workload_identity, identity); - runtime_descriptor.driver_fence.validate().unwrap(); + runtime_descriptor + .outer_fence + .validate(&runtime_descriptor.generation) + .unwrap(); let restart_metadata: RestartMetadata = serde_json::from_slice( supervisor .get(&PathBuf::from( diff --git a/crates/openshell-driver-vm/src/driver.rs b/crates/openshell-driver-vm/src/driver.rs index 1ad10f654b..9a82e6ef5e 100644 --- a/crates/openshell-driver-vm/src/driver.rs +++ b/crates/openshell-driver-vm/src/driver.rs @@ -1002,6 +1002,7 @@ impl VmDriver { .to_string_lossy() .into_owned(), rootfs_tar_max_bytes: self.config.rootfs_tar_max_bytes(), + supports_ui_policy: false, } } diff --git a/crates/openshell-driver-vm/src/isolation/mod.rs b/crates/openshell-driver-vm/src/isolation/mod.rs index 49c1135310..b313008095 100644 --- a/crates/openshell-driver-vm/src/isolation/mod.rs +++ b/crates/openshell-driver-vm/src/isolation/mod.rs @@ -9,14 +9,35 @@ //! the common control and boundary behavior. use openshell_isolation_interface::contract::{ - BackendError, DriverFenceEvidence, ResolvedWorkloadIdentity, + BackendError, OuterFenceGuarantees, ResolvedWorkloadIdentity, }; use openshell_sandbox_backend::boundary_protocol::{ BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, }; +use serde::Serialize; use std::collections::{BTreeMap, HashMap}; +#[derive(Serialize)] +struct VmOuterFenceEvidence<'a> { + generation: &'a str, + network_device_count: u32, +} + +impl VmOuterFenceEvidence<'_> { + fn project(&self) -> Result { + if self.generation.is_empty() || self.network_device_count != 0 { + return Err(BackendError::Descriptor( + "VM outer fence evidence is incomplete".to_string(), + )); + } + let encoded = serde_json::to_vec(self).map_err(|error| { + BackendError::Descriptor(format!("encode VM outer fence evidence: {error}")) + })?; + OuterFenceGuarantees::confirmed(self.generation, &encoded) + } +} + /// Driver-owned inputs that bind one VM generation to one supervisor boundary. pub struct VmBoundarySpec { pub boundary_id: String, @@ -57,10 +78,11 @@ impl VmBoundarySpec { ("vm.generation".to_string(), self.generation.clone()), ("vm.image_identity".to_string(), self.image_identity), ]); - let driver_fence = DriverFenceEvidence::Vm { - generation: self.generation.clone(), + let outer_fence = VmOuterFenceEvidence { + generation: &self.generation, network_device_count: 0, - }; + } + .project()?; Ok(VmBoundaryProvisioning { boundary_config: BoundaryConfig { boundary_id: self.boundary_id.clone(), @@ -77,7 +99,8 @@ impl VmBoundarySpec { resource_claims: resource_claims.clone(), resource_claim_files: BTreeMap::new(), workload_identity: workload_identity.clone(), - driver_fence: driver_fence.clone(), + outer_fence: outer_fence.clone(), + direct_proxy_url: None, child_env: self.child_env, }, runtime_descriptor: SandboxRuntimeDescriptor { @@ -91,8 +114,9 @@ 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, - driver_fence, + outer_fence, }, }) } @@ -155,14 +179,14 @@ mod tests { Some(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)) ); assert_eq!( - provisioned.boundary_config.driver_fence, - provisioned.runtime_descriptor.driver_fence + provisioned.boundary_config.outer_fence, + provisioned.runtime_descriptor.outer_fence ); assert!( provisioned .runtime_descriptor - .driver_fence - .validate() + .outer_fence + .validate("generation-1") .is_ok() ); } diff --git a/crates/openshell-gateway/src/lib.rs b/crates/openshell-gateway/src/lib.rs index b67e93c102..f134b60474 100644 --- a/crates/openshell-gateway/src/lib.rs +++ b/crates/openshell-gateway/src/lib.rs @@ -149,7 +149,15 @@ impl openshell_server::ComputeDriverFactory for MxcFactory { &self, context: openshell_server::ComputeDriverConfigContext<'_>, ) -> openshell_core::Result<()> { - let _: openshell_driver_mxc::MxcComputeConfig = context.driver_config()?; + let mut config: openshell_driver_mxc::MxcComputeConfig = context.driver_config()?; + if config.grpc_endpoint.trim().is_empty() { + let scheme = if context.gateway_tls_enabled() { + "https" + } else { + "http" + }; + config.grpc_endpoint = format!("{scheme}://127.0.0.1:{}", context.gateway_port()); + } Ok(()) } @@ -157,8 +165,29 @@ impl openshell_server::ComputeDriverFactory for MxcFactory { &self, context: openshell_server::ComputeDriverBuildContext<'_>, ) -> openshell_core::Result { - let config: openshell_driver_mxc::MxcComputeConfig = context.driver_config()?; - let backend = openshell_driver_mxc::MxcComputeBackend::new(config); + let mut config: openshell_driver_mxc::MxcComputeConfig = context.driver_config()?; + require_guest_tls_for_local_driver(&context, "mxc")?; + let use_internal_tls_server_name = + config.grpc_endpoint.trim().is_empty() && context.gateway_tls_enabled(); + if config.grpc_endpoint.trim().is_empty() { + let scheme = if context.gateway_tls_enabled() { + "https" + } else { + "http" + }; + config.grpc_endpoint = format!("{scheme}://127.0.0.1:{}", context.gateway_port()); + } + let tls = context + .guest_tls_paths() + .map(|(ca, cert, key)| (ca.to_path_buf(), cert.to_path_buf(), key.to_path_buf())); + let endpoint = config.grpc_endpoint.clone(); + let tls_server_name = use_internal_tls_server_name.then(|| "localhost".to_string()); + let backend = openshell_driver_mxc::MxcComputeBackend::new_with_gateway( + config, + endpoint, + tls, + tls_server_name, + ); let driver = openshell_driver_mxc::ComputeDriverService::new(backend); Ok(openshell_server::ComputeDriverInstance::InProcess( std::sync::Arc::new(driver), @@ -460,13 +489,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<'_>, @@ -479,13 +511,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-isolation-interface/Cargo.toml b/crates/openshell-isolation-interface/Cargo.toml index 94220dda73..0426a9011f 100644 --- a/crates/openshell-isolation-interface/Cargo.toml +++ b/crates/openshell-isolation-interface/Cargo.toml @@ -14,6 +14,8 @@ repository.workspace = true openshell-core = { path = "../openshell-core", default-features = false } async-trait = "0.1" serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } tokio = { workspace = true } [target.'cfg(unix)'.dependencies] diff --git a/crates/openshell-isolation-interface/src/contract.rs b/crates/openshell-isolation-interface/src/contract.rs index f671dd99e9..7bd8c1435a 100644 --- a/crates/openshell-isolation-interface/src/contract.rs +++ b/crates/openshell-isolation-interface/src/contract.rs @@ -16,7 +16,8 @@ //! //! Each transition consumes the prior state by value (`self: Box`). //! Trusted backend implementations construct confirmation through a validating -//! constructor; the supervisor cannot obtain a ready boundary without evidence. +//! constructor; the supervisor cannot obtain a ready boundary without confirmed +//! backend-neutral enforcement properties. //! The supervisor holds no `match`/downcast on concrete backends: the //! registry is the only lookup by `backend_name`, and everything past it is a //! `Box` / `Arc`. @@ -30,7 +31,7 @@ //! The contract is transport-neutral. Compute drivers keep runtime placement //! and coordination details behind these interfaces. -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::fmt; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; @@ -369,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 @@ -385,190 +395,187 @@ pub trait BoundBoundary: Send { async fn confirm(self: Box) -> Result; } -/// Capability masks measured from `/proc//status`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct CapabilityEvidence { - pub inheritable: u64, - pub permitted: u64, - pub effective: u64, - pub bounding: u64, - pub ambient: u64, +/// 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 CapabilityEvidence { - /// True only when every Linux capability set is empty. - #[must_use] - pub const fn is_empty(self) -> bool { - self.inheritable == 0 - && self.permitted == 0 - && self.effective == 0 - && self.bounding == 0 - && self.ambient == 0 +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() } } -/// Active seccomp notification and socket-broker evidence. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[allow( - clippy::struct_excessive_bools, - reason = "each independently measured kernel operation is reported explicitly" -)] -pub struct SeccompEvidence { - pub new_listener: bool, - pub notification_round_trip: bool, - pub id_validation: bool, - pub addfd_send: bool, - pub retained_socket_operation: bool, - pub proc_fd_identity: bool, - pub task_memory_read: bool, - pub task_memory_write: bool, - pub cancellation: bool, +/// Backend-neutral guarantees established by the compute driver's outer fence. +/// +/// Each driver owns its native evidence schema and the code that validates it. +/// After validation, the driver projects that evidence into these guarantees +/// and supplies a digest that binds the original evidence to this generation. +/// The common runtime only validates and compares this projection; it never +/// interprets runtime- or accelerator-specific fields. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OuterFenceGuarantee { + /// No workload packet can leave without an explicit mediated decision. + DefaultDenyEgress, + /// The driver found no network path outside the mediated boundary. + NoUnmanagedEgressPath, + /// Previously granted access can be revoked by the driver-owned fence. + RevocationVerified, + /// Loss of the driver or its controller does not open network access. + ControllerLossFailsClosed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OuterFenceGuarantees { + /// Sandbox generation for which the evidence was collected. + pub generation: String, + /// Complete set of normalized guarantees established by the driver. + pub established: BTreeSet, + /// Commitment to the driver-owned native evidence used for this projection. + pub evidence_digest: Sha256Digest, } -/// Driver-owned evidence that the mandatory outer network fence is installed. +impl OuterFenceGuarantees { + /// Construct guarantees after the driver has validated its native evidence. + pub fn confirmed( + generation: impl Into, + native_evidence: &[u8], + ) -> Result { + let generation = generation.into(); + if generation.is_empty() || native_evidence.is_empty() { + return Err(BackendError::Descriptor( + "outer fence generation and native evidence are required".to_string(), + )); + } + let mut binding = Vec::with_capacity(8 + generation.len() + native_evidence.len()); + binding.extend_from_slice(&(generation.len() as u64).to_be_bytes()); + binding.extend_from_slice(generation.as_bytes()); + binding.extend_from_slice(native_evidence); + Ok(Self { + generation, + established: BTreeSet::from([ + OuterFenceGuarantee::DefaultDenyEgress, + OuterFenceGuarantee::NoUnmanagedEgressPath, + OuterFenceGuarantee::RevocationVerified, + OuterFenceGuarantee::ControllerLossFailsClosed, + ]), + evidence_digest: Sha256Digest::compute(&binding), + }) + } + + /// Validate the common guarantees against the admitted generation. + pub fn validate(&self, expected_generation: &str) -> Result<(), BackendError> { + let required = BTreeSet::from([ + OuterFenceGuarantee::DefaultDenyEgress, + OuterFenceGuarantee::NoUnmanagedEgressPath, + OuterFenceGuarantee::RevocationVerified, + OuterFenceGuarantee::ControllerLossFailsClosed, + ]); + let complete = !self.generation.is_empty() + && self.generation == expected_generation + && self.established == required; + if complete { + Ok(()) + } else { + Err(BackendError::Confirm( + "outer fence guarantees are incomplete or bound to another generation".to_string(), + )) + } + } +} + +/// A backend-neutral security property established before agent launch. /// -/// The sandbox cannot observe the Docker daemon, Kubernetes API, or VM device -/// model directly. Drivers therefore bind the exact fence they validated into -/// both protected bootstrap halves. The sandbox reports that value back during -/// confirmation, and the supervisor rejects any mismatch before agent launch. +/// `mechanism` is diagnostic and audit metadata. It never authorizes launch; +/// the registered backend is responsible for validating its mechanism-specific +/// evidence before setting `enforced`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "backend", rename_all = "kebab-case", deny_unknown_fields)] -pub enum DriverFenceEvidence { - Docker { - container_id: String, - network_mode: String, - unexpected_networks: Vec, - }, - Podman { - container_id: String, - network_mode: String, - unexpected_networks: Vec, - }, - Kubernetes { - network_policy_uid: String, - network_policy_resource_version: String, - ingress_isolated: bool, - egress_isolated: bool, - egress_rule_count: u32, - }, - Vm { - generation: String, - network_device_count: u32, - }, +pub struct EnforcedProperty { + pub enforced: bool, + pub mechanism: String, } -impl DriverFenceEvidence { +impl EnforcedProperty { #[must_use] - pub const fn driver_name(&self) -> &'static str { - match self { - Self::Docker { .. } => "docker", - Self::Podman { .. } => "podman", - Self::Kubernetes { .. } => "kubernetes", - Self::Vm { .. } => "vm", + pub fn new(enforced: bool, mechanism: impl Into) -> Self { + Self { + enforced, + mechanism: mechanism.into(), } } - /// Validate the concrete outer-fence properties reported by the compute driver. - pub fn validate(&self) -> Result<(), BackendError> { - let valid = match self { - Self::Docker { - container_id, - network_mode, - unexpected_networks, - } - | Self::Podman { - container_id, - network_mode, - unexpected_networks, - } => { - !container_id.is_empty() && network_mode == "none" && unexpected_networks.is_empty() - } - Self::Kubernetes { - network_policy_uid, - network_policy_resource_version, - ingress_isolated, - egress_isolated, - egress_rule_count, - } => { - !network_policy_uid.is_empty() - && !network_policy_resource_version.is_empty() - && *ingress_isolated - && *egress_isolated - && *egress_rule_count == 0 - } - Self::Vm { - generation, - network_device_count, - } => !generation.is_empty() && *network_device_count == 0, - }; - if valid { + fn validate(&self, name: &str) -> Result<(), BackendError> { + if self.enforced && !self.mechanism.trim().is_empty() { Ok(()) } else { Err(BackendError::Confirm(format!( - "{} driver fence evidence is incomplete", - self.driver_name() + "{name} is not enforced or has no declared mechanism" ))) } } } -/// Measured sandbox-owned evidence produced before agent launch. +/// Security properties every isolation backend establishes before launch. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BoundaryProperties { + pub filesystem_confinement: EnforcedProperty, + pub egress_interception: EnforcedProperty, + pub request_attribution: EnforcedProperty, + pub privilege_floor: EnforcedProperty, +} + +impl BoundaryProperties { + fn validate(&self) -> Result<(), BackendError> { + self.filesystem_confinement + .validate("filesystem confinement")?; + self.egress_interception.validate("egress interception")?; + self.request_attribution.validate("request attribution")?; + self.privilege_floor.validate("privilege floor") + } +} + +/// Per-boundary confirmation produced before agent launch. +/// +/// Common validation binds the confirmation to the admitted workload and +/// checks backend-neutral properties. `backend_audit` remains opaque to this +/// crate; the registered backend owns its schema and validates it before +/// constructing [`ConfirmedBoundary`]. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[allow( - clippy::struct_excessive_bools, - reason = "confirmation preserves independently measured security results" -)] -pub struct SandboxConfirmEvidence { +pub struct BoundaryConfirmation { pub generation: String, pub identity: ResolvedWorkloadIdentity, - pub capabilities: CapabilityEvidence, - pub no_new_privileges: bool, - pub sandbox_dumpable: bool, - pub child_dumpable: bool, - pub core_limit_zero: bool, - pub native_architecture: String, - pub kernel_release: String, - pub seccomp: SeccompEvidence, - pub landlock_abi: u32, - pub landlock_allow_deny: bool, - pub udp_dns_round_trip: bool, - pub tcp_dns_round_trip: bool, - pub tcp_allow_round_trip: bool, - pub tcp_deny_round_trip: bool, + pub properties: BoundaryProperties, pub authenticated_supervisor: bool, pub session_id: SandboxSessionId, - pub driver_fence: DriverFenceEvidence, + pub outer_fence: OuterFenceGuarantees, /// The driver-owned containment primitive terminates the workload when its /// Sandbox Runtime exits. pub runtime_exit_terminates_workload: bool, pub resource_claims: BTreeMap, + pub backend_audit: serde_json::Value, } -impl SandboxConfirmEvidence { - /// Validate the security-critical evidence required before launch. +impl BoundaryConfirmation { + /// Validate common security properties and immutable launch binding. pub fn validate(&self, expected: &ResolvedWorkloadIdentity) -> Result<(), BackendError> { - self.driver_fence.validate()?; + self.outer_fence.validate(&self.generation)?; + self.properties.validate()?; let complete = &self.identity == expected - && self.capabilities.is_empty() - && self.no_new_privileges - && !self.sandbox_dumpable - && self.child_dumpable - && self.core_limit_zero - && self.seccomp.new_listener - && self.seccomp.notification_round_trip - && self.seccomp.id_validation - && self.seccomp.addfd_send - && self.seccomp.retained_socket_operation - && self.seccomp.proc_fd_identity - && self.seccomp.task_memory_read - && self.seccomp.task_memory_write - && self.seccomp.cancellation - && self.landlock_abi >= 3 - && self.landlock_allow_deny - && self.udp_dns_round_trip - && self.tcp_dns_round_trip - && self.tcp_allow_round_trip - && self.tcp_deny_round_trip && self.authenticated_supervisor && self.runtime_exit_terminates_workload && !self.generation.is_empty(); @@ -576,41 +583,45 @@ impl SandboxConfirmEvidence { Ok(()) } else { Err(BackendError::Confirm( - "sandbox confirmation evidence is incomplete or mismatched".to_string(), + "boundary confirmation is incomplete or mismatched".to_string(), )) } } } -/// Ready boundary paired with the evidence measured by `confirm`. +/// Ready boundary paired with the confirmation established by `confirm`. pub struct ConfirmedBoundary { boundary: Box, - evidence: SandboxConfirmEvidence, + confirmation: BoundaryConfirmation, } impl ConfirmedBoundary { - /// Construct confirmation after checking measured evidence against the - /// immutable identity admitted at attach time. + /// Construct confirmation after checking backend-neutral properties and + /// immutable identity binding. /// - /// Backend implementations are trusted to collect this evidence and bind - /// it to their resource. This constructor enforces the common requirements - /// without requiring those implementations to live in the interface crate. + /// Backend implementations are trusted to validate their audit evidence and + /// bind this confirmation to their resource. This constructor enforces the + /// common requirements without requiring those implementations to live in + /// the interface crate. /// /// # Errors /// - /// Returns an error if evidence is incomplete or the identity does not match. + /// Returns an error if confirmation is incomplete or the identity does not match. pub fn try_new( boundary: Box, - evidence: SandboxConfirmEvidence, + confirmation: BoundaryConfirmation, expected: &ResolvedWorkloadIdentity, ) -> Result { - evidence.validate(expected)?; - Ok(Self { boundary, evidence }) + confirmation.validate(expected)?; + Ok(Self { + boundary, + confirmation, + }) } - /// Return the measured evidence carried by this confirmed state. - pub fn evidence(&self) -> &SandboxConfirmEvidence { - &self.evidence + /// Return the record carried by this confirmed state. + pub fn confirmation(&self) -> &BoundaryConfirmation { + &self.confirmation } /// Consume confirmation and advance to the sole launch-capable state. @@ -831,7 +842,7 @@ pub trait BoundaryLoopbackConnector: Send + Sync { /// unavailable identity field cannot authorize the connection. How a backend /// resolves identity is private to that backend; the shape and the fail-closed /// semantics do not change. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct BinaryIdentity { /// Absolute path of the executable resolved for the accepted connection. pub binary_path: PathBuf, @@ -865,6 +876,12 @@ impl From for String { } impl Sha256Digest { + fn compute(bytes: &[u8]) -> Self { + use sha2::{Digest as _, Sha256}; + + Self(Sha256::digest(bytes).into()) + } + /// Return the raw digest bytes. #[must_use] pub fn as_bytes(&self) -> &[u8; 32] { diff --git a/crates/openshell-isolation-interface/src/lib.rs b/crates/openshell-isolation-interface/src/lib.rs index a7740d8a7d..b09320dc97 100644 --- a/crates/openshell-isolation-interface/src/lib.rs +++ b/crates/openshell-isolation-interface/src/lib.rs @@ -21,8 +21,10 @@ //! `start_agent` -> Running. Nothing untrusted runs inside the boundary until it //! is confirmed ready. This is enforced *by construction*: each transition //! consumes the prior state by value. Trusted backends construct confirmation -//! through [`contract::ConfirmedBoundary::try_new`], which checks common evidence -//! before the supervisor can obtain a [`contract::ReadyBoundary`]. +//! through [`contract::ConfirmedBoundary::try_new`], which checks common +//! enforcement properties and immutable launch binding before the supervisor +//! can obtain a [`contract::ReadyBoundary`]. Mechanism-specific evidence stays +//! owned by the backend that can interpret it. //! //! [`AgentSpec`] is shared between the workload definition the supervisor //! submits and the [`contract::SandboxContext`] that `attach` binds to a diff --git a/crates/openshell-isolation-interface/tests/backend_conformance.rs b/crates/openshell-isolation-interface/tests/backend_conformance.rs index 73559b203f..517b494356 100644 --- a/crates/openshell-isolation-interface/tests/backend_conformance.rs +++ b/crates/openshell-isolation-interface/tests/backend_conformance.rs @@ -239,7 +239,7 @@ impl BoundBoundary for MockBound { async fn confirm(self: Box) -> Result { ConfirmedBoundary::try_new( Box::new(MockReady:: { _k: PhantomData }), - confirmation_evidence(), + confirmation(), &workload_identity(), ) } @@ -368,80 +368,54 @@ fn workload_identity() -> ResolvedWorkloadIdentity { .unwrap() } -fn confirmation_evidence() -> SandboxConfirmEvidence { - SandboxConfirmEvidence { +fn confirmation() -> BoundaryConfirmation { + BoundaryConfirmation { generation: "generation-1".to_string(), identity: workload_identity(), - capabilities: CapabilityEvidence { - inheritable: 0, - permitted: 0, - effective: 0, - bounding: 0, - ambient: 0, + properties: BoundaryProperties { + filesystem_confinement: EnforcedProperty::new(true, "mock-filesystem"), + egress_interception: EnforcedProperty::new(true, "mock-egress"), + request_attribution: EnforcedProperty::new(true, "mock-attribution"), + privilege_floor: EnforcedProperty::new(true, "mock-privilege-floor"), }, - no_new_privileges: true, - sandbox_dumpable: false, - child_dumpable: true, - core_limit_zero: true, - native_architecture: std::env::consts::ARCH.to_string(), - kernel_release: "test".to_string(), - seccomp: SeccompEvidence { - new_listener: true, - notification_round_trip: true, - id_validation: true, - addfd_send: true, - retained_socket_operation: true, - proc_fd_identity: true, - task_memory_read: true, - task_memory_write: true, - cancellation: true, - }, - landlock_abi: 3, - landlock_allow_deny: true, - udp_dns_round_trip: true, - tcp_dns_round_trip: true, - tcp_allow_round_trip: true, - tcp_deny_round_trip: true, authenticated_supervisor: true, session_id: SandboxSessionId::new(), - driver_fence: DriverFenceEvidence::Vm { - generation: "generation-1".to_string(), - network_device_count: 0, - }, + outer_fence: OuterFenceGuarantees::confirmed("generation-1", b"mock-fence-evidence") + .unwrap(), runtime_exit_terminates_workload: true, resource_claims: BTreeMap::new(), + backend_audit: serde_json::json!({"backend": "mock"}), } } #[test] -fn driver_fence_evidence_is_backend_specific_and_fail_closed() { - let docker = DriverFenceEvidence::Docker { - container_id: "sha256:container".to_string(), - network_mode: "none".to_string(), - unexpected_networks: Vec::new(), - }; - let kubernetes = DriverFenceEvidence::Kubernetes { - network_policy_uid: "policy-uid".to_string(), - network_policy_resource_version: "42".to_string(), - ingress_isolated: true, - egress_isolated: true, - egress_rule_count: 0, - }; - let vm = DriverFenceEvidence::Vm { - generation: "generation-1".to_string(), - network_device_count: 0, - }; +fn outer_fence_guarantees_are_backend_neutral_and_fail_closed() { + let fence = OuterFenceGuarantees::confirmed("generation-1", b"native-driver-evidence").unwrap(); + assert!(fence.validate("generation-1").is_ok()); + assert_ne!( + fence.evidence_digest, + OuterFenceGuarantees::confirmed("generation-2", b"native-driver-evidence") + .unwrap() + .evidence_digest + ); - assert!(docker.validate().is_ok()); - assert!(kubernetes.validate().is_ok()); - assert!(vm.validate().is_ok()); + let mut wrong_generation = fence.clone(); + wrong_generation.generation = "generation-2".to_string(); + assert!(wrong_generation.validate("generation-1").is_err()); - let drifted = DriverFenceEvidence::Docker { - container_id: "sha256:container".to_string(), - network_mode: "bridge".to_string(), - unexpected_networks: vec!["bridge".to_string()], - }; - assert!(drifted.validate().is_err()); + for guarantee in [ + OuterFenceGuarantee::DefaultDenyEgress, + OuterFenceGuarantee::NoUnmanagedEgressPath, + OuterFenceGuarantee::RevocationVerified, + OuterFenceGuarantee::ControllerLossFailsClosed, + ] { + let mut incomplete = fence.clone(); + incomplete.established.remove(&guarantee); + assert!(incomplete.validate("generation-1").is_err()); + } + + assert!(OuterFenceGuarantees::confirmed("", b"evidence").is_err()); + assert!(OuterFenceGuarantees::confirmed("generation-1", b"").is_err()); } /// The backend-independent supervisor sequence. Identical for every backend: @@ -458,7 +432,7 @@ async fn drive( let _ingress = bound.network_mediation_source(); assert_eq!(bound.host_gateway_ip(), None); let confirmed = bound.confirm().await?; - confirmed.evidence().validate(&sandbox_ctx().identity)?; + confirmed.confirmation().validate(&sandbox_ctx().identity)?; confirmed.into_boundary().start_agent().await } @@ -536,12 +510,12 @@ async fn one_driver_runs_both_backends() { } #[test] -fn confirmation_constructor_rejects_incomplete_evidence() { - let mut evidence = confirmation_evidence(); - evidence.seccomp.cancellation = false; +fn confirmation_constructor_rejects_unenforced_property() { + let mut confirmation = confirmation(); + confirmation.properties.egress_interception.enforced = false; let result = ConfirmedBoundary::try_new( Box::new(MockReady:: { _k: PhantomData }), - evidence, + confirmation, &workload_identity(), ); assert!(matches!(result, Err(BackendError::Confirm(_)))); @@ -559,7 +533,7 @@ fn confirmation_constructor_rejects_another_workload_identity() { .unwrap(); let result = ConfirmedBoundary::try_new( Box::new(MockReady:: { _k: PhantomData }), - confirmation_evidence(), + confirmation(), &expected, ); assert!(matches!(result, Err(BackendError::Confirm(_)))); @@ -842,16 +816,16 @@ fn workload_identity_rejects_root_and_normalizes_groups() { } #[test] -fn confirmation_evidence_rejects_identity_or_posture_drift() { +fn confirmation_rejects_identity_or_property_drift() { let expected = workload_identity(); - let evidence = confirmation_evidence(); - evidence.validate(&expected).unwrap(); + let baseline = confirmation(); + baseline.validate(&expected).unwrap(); - let mut drifted = confirmation_evidence(); - drifted.capabilities.effective = 1; + let mut drifted = confirmation(); + drifted.properties.privilege_floor.enforced = false; assert!(drifted.validate(&expected).is_err()); - let mut unmanaged = confirmation_evidence(); + let mut unmanaged = confirmation(); unmanaged.runtime_exit_terminates_workload = false; assert!(unmanaged.validate(&expected).is_err()); @@ -863,7 +837,7 @@ fn confirmation_evidence_rejects_identity_or_posture_drift() { "sha256:test".into(), ) .unwrap(); - assert!(evidence.validate(&different).is_err()); + assert!(baseline.validate(&different).is_err()); } // --------------------------------------------------------------------------- diff --git a/crates/openshell-policy-schema/src/lib.rs b/crates/openshell-policy-schema/src/lib.rs index 62c2b06dfe..163f9071b8 100644 --- a/crates/openshell-policy-schema/src/lib.rs +++ b/crates/openshell-policy-schema/src/lib.rs @@ -155,6 +155,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")] @@ -196,6 +202,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 { @@ -660,6 +694,7 @@ fn inspect_document(root: &serde_yml::Value) -> InspectionResult { "filesystem_policy", "landlock", "process", + "ui", "network_policies", "network_middlewares", ], @@ -679,6 +714,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); @@ -1140,6 +1180,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", ] { @@ -1176,6 +1217,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 a9d2ab0bbf..9c94d3d003 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -29,9 +29,27 @@ use openshell_core::mcp::{DEFAULT_MCP_PROTOCOL_VERSION, McpProtocolVersion}; 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, @@ -60,6 +78,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 { @@ -599,6 +618,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, }) @@ -642,6 +666,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() @@ -780,6 +810,7 @@ fn from_proto(policy: &SandboxPolicy) -> Result { filesystem_policy, landlock, process, + ui, network_policies, network_middlewares, }) @@ -955,6 +986,7 @@ pub fn restrictive_default_policy() -> SandboxPolicy { compatibility: "best_effort".into(), }), process: None, + ui: None, network_policies: HashMap::new(), network_middlewares: HashMap::default(), } @@ -990,6 +1022,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 `/`). @@ -1091,6 +1125,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}") } @@ -1297,6 +1337,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 `/`) @@ -1356,6 +1397,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, + }); + } + // Check filesystem paths if let Some(ref fs) = policy.filesystem { let total_paths = fs.read_only.len() + fs.read_write.len(); @@ -1800,6 +1849,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) @@ -1822,6 +1872,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" @@ -1829,6 +1880,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) { @@ -1948,6 +2013,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() { @@ -3574,6 +3732,7 @@ network_policies: process: None, filesystem: None, landlock: None, + ui: None, network_policies: HashMap::new(), network_middlewares: HashMap::default(), }; @@ -4032,6 +4191,7 @@ network_policies: }), filesystem: None, landlock: None, + ui: None, network_policies: HashMap::new(), network_middlewares: HashMap::default(), }; @@ -4048,6 +4208,7 @@ network_policies: }), filesystem: None, landlock: None, + ui: None, network_policies: HashMap::new(), network_middlewares: HashMap::default(), }; @@ -4120,6 +4281,7 @@ network_policies: }), filesystem: None, landlock: None, + ui: None, network_policies: HashMap::new(), network_middlewares: HashMap::default(), }; diff --git a/crates/openshell-sandbox-backend/src/boundary_protocol.rs b/crates/openshell-sandbox-backend/src/boundary_protocol.rs index e619d68dc2..b319f524b5 100644 --- a/crates/openshell-sandbox-backend/src/boundary_protocol.rs +++ b/crates/openshell-sandbox-backend/src/boundary_protocol.rs @@ -23,8 +23,9 @@ use openshell_core::policy::{ use openshell_isolation_interface::AgentSpec; use openshell_isolation_interface::contract::Sha256Digest; use openshell_isolation_interface::contract::{ - BackendDescriptor, BackendError, BinaryIdentity, BoundaryExitStatus, BoundarySignal, - DriverFenceEvidence, ExecSpec, ResolveError, SandboxConfirmEvidence, + BackendDescriptor, BackendError, BinaryIdentity, BoundaryConfirmation, BoundaryExitStatus, + BoundaryProperties, BoundarySignal, EnforcedProperty, ExecSpec, OuterFenceGuarantees, + ResolveError, }; use rcgen::{CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose}; use serde::de::DeserializeOwned; @@ -42,6 +43,231 @@ pub const STREAM_STDIN_CLOSED: u8 = 4; pub const STREAM_NETWORK_DECISION: u8 = 5; pub const MAX_STREAM_FRAME_BYTES: usize = 64 * 1024; +/// Capability masks measured from `/proc//status` by the `OpenShell` +/// co-located runtime. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityEvidence { + pub inheritable: u64, + pub permitted: u64, + pub effective: u64, + pub bounding: u64, + pub ambient: u64, +} + +impl CapabilityEvidence { + #[must_use] + pub const fn is_empty(self) -> bool { + self.inheritable == 0 + && self.permitted == 0 + && self.effective == 0 + && self.bounding == 0 + && self.ambient == 0 + } +} + +/// Active seccomp notification and socket-broker measurements specific to the +/// `OpenShell` co-located runtime. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[allow( + clippy::struct_excessive_bools, + reason = "each independently measured kernel operation is reported explicitly" +)] +pub struct SeccompEvidence { + pub new_listener: bool, + pub notification_round_trip: bool, + pub id_validation: bool, + pub addfd_send: bool, + pub retained_socket_operation: bool, + pub proc_fd_identity: bool, + pub task_memory_read: bool, + pub task_memory_write: bool, + pub cancellation: bool, +} + +/// Mechanism-specific audit evidence for the `OpenShell` co-located runtime. +/// +/// This schema belongs to this backend rather than the generic isolation +/// interface. The host-side backend validates it before constructing a +/// backend-neutral `ConfirmedBoundary`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[allow( + clippy::struct_excessive_bools, + reason = "audit evidence preserves independently measured security results" +)] +pub struct OpenShellSandboxAuditEvidence { + pub capabilities: CapabilityEvidence, + pub no_new_privileges: bool, + pub sandbox_dumpable: bool, + pub child_dumpable: bool, + pub core_limit_zero: bool, + pub native_architecture: String, + pub kernel_release: String, + pub seccomp: SeccompEvidence, + pub landlock_abi: u32, + pub landlock_allow_deny: bool, + pub udp_dns_round_trip: bool, + pub tcp_dns_round_trip: bool, + pub tcp_allow_round_trip: bool, + pub tcp_deny_round_trip: bool, +} + +impl OpenShellSandboxAuditEvidence { + /// Validate the complete mechanism-specific posture required by this backend. + pub fn validate(&self) -> Result<(), BackendError> { + let complete = self.capabilities.is_empty() + && self.no_new_privileges + && !self.sandbox_dumpable + && self.child_dumpable + && self.core_limit_zero + && !self.native_architecture.is_empty() + && !self.kernel_release.is_empty() + && self.seccomp.new_listener + && self.seccomp.notification_round_trip + && self.seccomp.id_validation + && self.seccomp.addfd_send + && self.seccomp.retained_socket_operation + && self.seccomp.proc_fd_identity + && self.seccomp.task_memory_read + && self.seccomp.task_memory_write + && self.seccomp.cancellation + && self.landlock_abi >= 3 + && self.landlock_allow_deny + && self.udp_dns_round_trip + && self.tcp_dns_round_trip + && self.tcp_allow_round_trip + && self.tcp_deny_round_trip; + if complete { + Ok(()) + } else { + Err(BackendError::Confirm( + "OpenShell sandbox audit evidence is incomplete".to_string(), + )) + } + } + + /// Project backend measurements into the common property contract. + #[must_use] + pub fn properties(&self) -> BoundaryProperties { + BoundaryProperties { + filesystem_confinement: EnforcedProperty::new( + self.landlock_abi >= 3 && self.landlock_allow_deny, + format!("landlock-v{}", self.landlock_abi), + ), + egress_interception: EnforcedProperty::new( + self.seccomp.new_listener + && self.seccomp.notification_round_trip + && self.seccomp.addfd_send + && self.udp_dns_round_trip + && self.tcp_dns_round_trip + && self.tcp_allow_round_trip + && self.tcp_deny_round_trip, + "seccomp-notify", + ), + request_attribution: EnforcedProperty::new( + self.seccomp.id_validation + && self.seccomp.proc_fd_identity + && self.seccomp.task_memory_read + && self.seccomp.task_memory_write, + "seccomp-notify-procfs", + ), + privilege_floor: EnforcedProperty::new( + self.capabilities.is_empty() + && self.no_new_privileges + && !self.sandbox_dumpable + && self.child_dumpable + && self.core_limit_zero, + "linux-capability-free", + ), + } + } +} + +/// 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 { + 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", + ), + } + } +} + +/// Backend-owned audit formats understood by this Sandbox Protocol backend. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "platform", content = "evidence", rename_all = "snake_case")] +pub enum OpenShellBoundaryAuditEvidence { + Linux(OpenShellSandboxAuditEvidence), + WindowsMxc(MxcSandboxAuditEvidence), +} + +impl OpenShellBoundaryAuditEvidence { + pub fn validate(&self) -> Result<(), BackendError> { + match self { + Self::Linux(evidence) => evidence.validate(), + Self::WindowsMxc(evidence) => evidence.validate(), + } + } + + #[must_use] + pub fn properties(&self) -> BoundaryProperties { + match self { + Self::Linux(evidence) => evidence.properties(), + Self::WindowsMxc(evidence) => evidence.properties(), + } + } +} + /// Ephemeral identity of the supervisor process that owns one sandbox runtime. /// /// The supervisor generates this value in memory and presents it on every @@ -282,12 +508,18 @@ 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)] pub resource_claims: std::collections::BTreeMap, - /// Concrete outer-fence evidence validated by the driver. - pub driver_fence: DriverFenceEvidence, + /// Backend-neutral projection of the driver-validated outer fence. + pub outer_fence: OuterFenceGuarantees, } impl fmt::Debug for SandboxRuntimeDescriptor { @@ -300,8 +532,9 @@ 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("driver_fence", &self.driver_fence) + .field("outer_fence", &self.outer_fence) .finish() } } @@ -354,8 +587,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, - /// Concrete outer-fence evidence validated by the driver. - pub driver_fence: DriverFenceEvidence, + /// Backend-neutral projection of the driver-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, @@ -383,7 +621,11 @@ impl fmt::Debug for BoundaryConfig { .field("resource_claims", &self.resource_claims) .field("resource_claim_files", &self.resource_claim_files) .field("workload_identity", &self.workload_identity) - .field("driver_fence", &self.driver_fence) + .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() } @@ -704,8 +946,9 @@ pub enum Response { snapshot: SessionSnapshotWire, }, Confirmed { - /// Measured capability-free posture produced before workload launch. - evidence: Box, + /// Backend-neutral properties and backend-owned audit evidence produced + /// before workload launch. + confirmation: Box, }, Started { process_id: String, @@ -1184,6 +1427,61 @@ pub enum FrameError { mod tests { use super::*; + fn complete_audit_evidence() -> OpenShellSandboxAuditEvidence { + OpenShellSandboxAuditEvidence { + capabilities: CapabilityEvidence { + inheritable: 0, + permitted: 0, + effective: 0, + bounding: 0, + ambient: 0, + }, + no_new_privileges: true, + sandbox_dumpable: false, + child_dumpable: true, + core_limit_zero: true, + native_architecture: "x86_64".to_string(), + kernel_release: "6.12.0".to_string(), + seccomp: SeccompEvidence { + new_listener: true, + notification_round_trip: true, + id_validation: true, + addfd_send: true, + retained_socket_operation: true, + proc_fd_identity: true, + task_memory_read: true, + task_memory_write: true, + cancellation: true, + }, + landlock_abi: 6, + landlock_allow_deny: true, + udp_dns_round_trip: true, + tcp_dns_round_trip: true, + tcp_allow_round_trip: true, + tcp_deny_round_trip: true, + } + } + + #[test] + fn openshell_audit_evidence_projects_backend_neutral_properties() { + let audit = complete_audit_evidence(); + audit.validate().unwrap(); + let properties = audit.properties(); + assert!(properties.filesystem_confinement.enforced); + assert_eq!(properties.filesystem_confinement.mechanism, "landlock-v6"); + assert!(properties.egress_interception.enforced); + assert!(properties.request_attribution.enforced); + assert!(properties.privilege_floor.enforced); + } + + #[test] + fn openshell_audit_evidence_rejects_mechanism_failure() { + let mut audit = complete_audit_evidence(); + audit.seccomp.addfd_send = false; + assert!(audit.validate().is_err()); + assert!(!audit.properties().egress_interception.enforced); + } + #[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 29558ebae0..609034402b 100644 --- a/crates/openshell-sandbox-backend/src/runtime.rs +++ b/crates/openshell-sandbox-backend/src/runtime.rs @@ -99,10 +99,11 @@ 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; - let driver_fence = runtime_descriptor.driver_fence.clone(); + let outer_fence = runtime_descriptor.outer_fence.clone(); let client = Arc::new(BoundaryClient::new( runtime_descriptor, self.sandbox_bearer.clone(), @@ -129,13 +130,14 @@ 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, generation, session_id, resource_claims, - driver_fence, + outer_fence, })) } } @@ -167,7 +169,9 @@ fn validate_runtime_descriptor( )); } validate_resource_claims(&runtime_descriptor.resource_claims)?; - runtime_descriptor.driver_fence.validate()?; + runtime_descriptor + .outer_fence + .validate(&runtime_descriptor.generation)?; match &runtime_descriptor.transport { SandboxTransport::Unix { socket_path } => { validate_socket_path(socket_path)?; @@ -196,6 +200,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.binary_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(()) } @@ -272,13 +288,14 @@ 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, generation: String, session_id: openshell_core::SandboxSessionId, resource_claims: std::collections::BTreeMap, - driver_fence: openshell_isolation_interface::contract::DriverFenceEvidence, + outer_fence: openshell_isolation_interface::contract::OuterFenceGuarantees, } #[async_trait] @@ -291,21 +308,37 @@ 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 { evidence } = response else { - return Err(unexpected_response("confirmed_with_evidence", &response)); + let Response::Confirmed { confirmation } = response else { + return Err(unexpected_response("confirmed", &response)); }; - if evidence.generation != self.generation - || evidence.session_id != self.session_id - || evidence.resource_claims != self.resource_claims - || evidence.driver_fence != self.driver_fence + if confirmation.generation != self.generation + || confirmation.session_id != self.session_id + || confirmation.resource_claims != self.resource_claims + || confirmation.outer_fence != self.outer_fence { return Err(BackendError::Confirm( - "sandbox confirmation generation, session, resource claims, or driver fence do not match runtime descriptor" + "sandbox confirmation generation, session, resource claims, or outer fence do not match runtime descriptor" .to_string(), )); } + let audit: crate::boundary_protocol::OpenShellBoundaryAuditEvidence = + serde_json::from_value(confirmation.backend_audit.clone()).map_err(|error| { + BackendError::Confirm(format!("decode OpenShell sandbox audit evidence: {error}")) + })?; + audit.validate()?; + if confirmation.properties != audit.properties() { + return Err(BackendError::Confirm( + "sandbox confirmation properties do not match OpenShell audit evidence".to_string(), + )); + } self.client.start_credential_monitor(); ConfirmedBoundary::try_new( Box::new(RemoteReady { @@ -316,7 +349,7 @@ impl BoundBoundary for RemoteBound { ca_file_paths: self.ca_file_paths, provider_credentials: self.provider_credentials, }), - *evidence, + *confirmation, &self.identity, ) } @@ -1845,11 +1878,12 @@ mod tests { FilesystemPolicy, LandlockPolicy, NetworkPolicy, ProcessPolicy, SandboxPolicy, }; - fn test_driver_fence() -> openshell_isolation_interface::contract::DriverFenceEvidence { - openshell_isolation_interface::contract::DriverFenceEvidence::Vm { - generation: "test-generation".to_string(), - network_device_count: 0, - } + fn test_outer_fence() -> openshell_isolation_interface::contract::OuterFenceGuarantees { + openshell_isolation_interface::contract::OuterFenceGuarantees::confirmed( + "test-generation", + b"test-vm-fence", + ) + .unwrap() } #[tokio::test] @@ -1939,7 +1973,7 @@ mod tests { }, }, Request::Confirm => Response::Confirmed { - evidence: Box::new(test_confirmation_evidence()), + confirmation: Box::new(test_confirmation()), }, Request::OpenMediation if mediation_ready => Response::MediationReady, Request::OpenMediation => Response::Error { @@ -2352,8 +2386,9 @@ mod tests { }, tls, host_gateway_ip: None, + direct_proxy: None, resource_claims: std::collections::BTreeMap::new(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), } } @@ -2437,12 +2472,9 @@ mod tests { } } - fn test_confirmation_evidence() - -> openshell_isolation_interface::contract::SandboxConfirmEvidence { - openshell_isolation_interface::contract::SandboxConfirmEvidence { - generation: "test-generation".to_string(), - identity: sandbox().identity, - capabilities: openshell_isolation_interface::contract::CapabilityEvidence { + fn test_confirmation() -> openshell_isolation_interface::contract::BoundaryConfirmation { + let audit = crate::boundary_protocol::OpenShellSandboxAuditEvidence { + capabilities: crate::boundary_protocol::CapabilityEvidence { inheritable: 0, permitted: 0, effective: 0, @@ -2455,7 +2487,7 @@ mod tests { core_limit_zero: true, native_architecture: std::env::consts::ARCH.to_string(), kernel_release: "test".to_string(), - seccomp: openshell_isolation_interface::contract::SeccompEvidence { + seccomp: crate::boundary_protocol::SeccompEvidence { new_listener: true, notification_round_trip: true, id_validation: true, @@ -2472,11 +2504,17 @@ mod tests { tcp_dns_round_trip: true, tcp_allow_round_trip: true, tcp_deny_round_trip: true, + }; + openshell_isolation_interface::contract::BoundaryConfirmation { + generation: "test-generation".to_string(), + identity: sandbox().identity, + properties: audit.properties(), authenticated_supervisor: true, session_id: test_session_id(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), runtime_exit_terminates_workload: true, resource_claims: std::collections::BTreeMap::new(), + backend_audit: serde_json::to_value(audit).expect("serialize audit evidence"), } } @@ -2493,8 +2531,9 @@ 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(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), }; let debug = format!("{runtime_descriptor:?}"); assert!(debug.contains("")); @@ -2513,8 +2552,9 @@ mod tests { }, tls: test_certificate().client_tls, host_gateway_ip: None, + direct_proxy: None, resource_claims: std::collections::BTreeMap::new(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), }; assert!(matches!( validate_runtime_descriptor(&runtime_descriptor, &sandbox()), @@ -2535,8 +2575,9 @@ mod tests { }, tls: test_certificate().client_tls, host_gateway_ip: None, + direct_proxy: None, resource_claims: std::collections::BTreeMap::new(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), }; assert!(matches!( validate_runtime_descriptor(&runtime_descriptor, &sandbox()), @@ -2557,8 +2598,9 @@ mod tests { }, tls: test_certificate().client_tls, host_gateway_ip: None, + direct_proxy: None, resource_claims: std::collections::BTreeMap::new(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), }; validate_runtime_descriptor(&runtime_descriptor, &sandbox()) .expect("TCP runtime descriptor should be valid"); @@ -2594,7 +2636,7 @@ mod tests { .await .expect("TLS request"), Response::Confirmed { - evidence: Box::new(test_confirmation_evidence()), + confirmation: Box::new(test_confirmation()), } ); server.abort(); @@ -2750,8 +2792,9 @@ mod tests { }, tls: certificate.client_tls, host_gateway_ip: None, + direct_proxy: None, resource_claims: std::collections::BTreeMap::new(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), }, test_bearer(&"a".repeat(32)), )); @@ -2837,8 +2880,9 @@ mod tests { }, tls: certificate.client_tls, host_gateway_ip: None, + direct_proxy: None, resource_claims: std::collections::BTreeMap::new(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), }, test_bearer(&"a".repeat(32)), ); diff --git a/crates/openshell-sandbox/Cargo.toml b/crates/openshell-sandbox/Cargo.toml index 1283dd063b..18d762bb5a 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 } @@ -77,6 +78,9 @@ seccompiler = "0.5" socket2 = { workspace = true } tempfile = "3" +[target.'cfg(windows)'.dependencies] +windows = { workspace = true } + [dev-dependencies] rcgen = { workspace = true } tempfile = "3" diff --git a/crates/openshell-sandbox/src/boundary_server.rs b/crates/openshell-sandbox/src/boundary_server.rs index 3326570d96..670649273c 100644 --- a/crates/openshell-sandbox/src/boundary_server.rs +++ b/crates/openshell-sandbox/src/boundary_server.rs @@ -11,6 +11,9 @@ use std::path::Path; +#[cfg(target_os = "windows")] +mod windows; + #[cfg(target_os = "linux")] mod linux { use std::fs::File; @@ -36,9 +39,8 @@ mod linux { }; use openshell_core::provider_credentials::ProviderCredentialState; use openshell_isolation_interface::contract::{ - BoundaryExec, BoundaryLoopbackConnector, BoundaryProcess, BoundaryTerminal, - CapabilityEvidence, ExecSession, LoopbackTarget, ResolvedWorkloadIdentity, - SandboxConfirmEvidence, + BoundaryConfirmation, BoundaryExec, BoundaryLoopbackConnector, BoundaryProcess, + BoundaryTerminal, ExecSession, LoopbackTarget, ResolvedWorkloadIdentity, }; use openshell_sandbox_backend::GPU_RESOURCE_CLAIM; use openshell_sandbox_backend::mediation::{ @@ -60,11 +62,12 @@ mod linux { use openshell_sandbox_backend::boundary_protocol::{ AgentSpecWire, BinaryIdentityWire, BoundaryConfig, BoundaryErrorKind, BoundaryListener as BoundaryListenerConfig, DnsQueryResultWire, ExecSpecWire, - ExitStatusWire, MediationTimingWire, OutputWindowWire, ProcessKindWire, - ProcessSnapshotWire, Request, RequestEnvelope, Response, ResponseEnvelope, STREAM_EXIT, - STREAM_NETWORK_DECISION, STREAM_STDERR, STREAM_STDIN, STREAM_STDIN_CLOSED, STREAM_STDOUT, - SandboxPolicyWire, SessionSnapshotWire, SignalWire, encode_frame, read_frame, - read_stream_frame, validate_resource_claims, write_frame, write_stream_frame, + ExitStatusWire, MediationTimingWire, OpenShellBoundaryAuditEvidence, + OpenShellSandboxAuditEvidence, OutputWindowWire, ProcessKindWire, ProcessSnapshotWire, + Request, RequestEnvelope, Response, ResponseEnvelope, STREAM_EXIT, STREAM_NETWORK_DECISION, + STREAM_STDERR, STREAM_STDIN, STREAM_STDIN_CLOSED, STREAM_STDOUT, SandboxPolicyWire, + SessionSnapshotWire, SignalWire, encode_frame, read_frame, read_stream_frame, + validate_resource_claims, write_frame, write_stream_frame, }; const CONTROL_IO_TIMEOUT: Duration = Duration::from_secs(30); @@ -304,8 +307,8 @@ mod linux { } validate_resource_claims(&config.resource_claims).map_err(|error| error.to_string())?; config - .driver_fence - .validate() + .outer_fence + .validate(&config.generation) .map_err(|error| error.to_string())?; for (claim, path) in &config.resource_claim_files { if !config.resource_claims.contains_key(claim) { @@ -2220,20 +2223,20 @@ mod linux { if let Err(error) = prepared.confirm(&self.process_runtime) { return guest_error(BoundaryErrorKind::Process, error); } - let evidence = match self.measure_confirmation_evidence() { - Ok(evidence) => evidence, + let confirmation = match self.measure_confirmation() { + Ok(confirmation) => confirmation, Err(error) => return guest_error(BoundaryErrorKind::Process, error), }; *state = RuntimeState::Ready(prepared.clone()); Response::Confirmed { - evidence: Box::new(evidence), + confirmation: Box::new(confirmation), } } RuntimeState::Ready(_) | RuntimeState::Running(_) => { - self.measure_confirmation_evidence().map_or_else( + self.measure_confirmation().map_or_else( |error| guest_error(BoundaryErrorKind::Process, error), - |evidence| Response::Confirmed { - evidence: Box::new(evidence), + |confirmation| Response::Confirmed { + confirmation: Box::new(confirmation), }, ) } @@ -2244,7 +2247,7 @@ mod linux { } } - fn measure_confirmation_evidence(&self) -> Result { + fn measure_confirmation(&self) -> Result { validate_running_identity( &self.config.workload_identity, allows_runtime_supplementary_groups(&self.config), @@ -2257,7 +2260,7 @@ mod linux { } let status = std::fs::read_to_string("/proc/self/status") .map_err(|error| format!("read sandbox process status: {error}"))?; - let capabilities = CapabilityEvidence { + let capabilities = openshell_sandbox_backend::boundary_protocol::CapabilityEvidence { inheritable: parse_status_hex(&status, "CapInh")?, permitted: parse_status_hex(&status, "CapPrm")?, effective: parse_status_hex(&status, "CapEff")?, @@ -2278,9 +2281,7 @@ mod linux { // SAFETY: successful getrlimit initialized the value. let core_limit = unsafe { core_limit.assume_init() }; let (native_architecture, kernel_release) = uname_values()?; - Ok(SandboxConfirmEvidence { - generation: self.config.generation.clone(), - identity: self.config.workload_identity.clone(), + let audit = OpenShellSandboxAuditEvidence { capabilities, no_new_privileges, sandbox_dumpable, @@ -2295,11 +2296,24 @@ mod linux { tcp_dns_round_trip: self.qualification.tcp_dns_round_trip, tcp_allow_round_trip: self.qualification.tcp_allow_round_trip, tcp_deny_round_trip: self.qualification.tcp_deny_round_trip, + }; + // The boundary reports mechanism evidence; the authenticated host + // backend validates it before constructing a ConfirmedBoundary. + // Keeping that decision at the verifier also lets lifecycle tests + // exercise the protocol without claiming host-kernel enforcement. + let properties = audit.properties(); + let backend_audit = serde_json::to_value(OpenShellBoundaryAuditEvidence::Linux(audit)) + .map_err(|error| format!("encode OpenShell sandbox 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, - driver_fence: self.config.driver_fence.clone(), + outer_fence: self.config.outer_fence.clone(), runtime_exit_terminates_workload: true, resource_claims: self.config.resource_claims.clone(), + backend_audit, }) } @@ -3639,7 +3653,8 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), + direct_proxy_url: None, child_env: std::collections::HashMap::new(), }; let debug = format!("{config:?}"); @@ -3790,7 +3805,8 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), + direct_proxy_url: None, child_env: std::collections::HashMap::new(), }, tokio::runtime::Handle::current(), @@ -4192,16 +4208,17 @@ mod linux { .unwrap() } - fn test_driver_fence() -> openshell_isolation_interface::contract::DriverFenceEvidence { - openshell_isolation_interface::contract::DriverFenceEvidence::Vm { - generation: "generation-1".to_string(), - network_device_count: 0, - } + fn test_outer_fence() -> openshell_isolation_interface::contract::OuterFenceGuarantees { + openshell_isolation_interface::contract::OuterFenceGuarantees::confirmed( + "generation-1", + b"test-vm-fence", + ) + .unwrap() } fn test_runtime_qualification() -> crate::RuntimeQualification { crate::RuntimeQualification { - seccomp: openshell_isolation_interface::contract::SeccompEvidence { + seccomp: openshell_sandbox_backend::boundary_protocol::SeccompEvidence { new_listener: true, notification_round_trip: true, id_validation: true, @@ -4304,7 +4321,8 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), + direct_proxy_url: None, child_env: std::collections::HashMap::new(), }; @@ -4339,7 +4357,8 @@ mod linux { pod_uid_path, )]), workload_identity: test_workload_identity(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), + direct_proxy_url: None, child_env: std::collections::HashMap::new(), }; @@ -4379,7 +4398,8 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), + direct_proxy_url: None, child_env: std::collections::HashMap::new(), }, tokio::runtime::Handle::current(), @@ -4554,7 +4574,8 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), + direct_proxy_url: None, child_env: std::collections::HashMap::new(), }, process_runtime.handle().clone(), @@ -4827,7 +4848,8 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), + direct_proxy_url: None, child_env: std::collections::HashMap::new(), }, process_runtime.handle().clone(), @@ -5081,10 +5103,18 @@ pub fn run_boundary( linux::run_boundary(config_path, qualification) } -#[cfg(not(target_os = "linux"))] +#[cfg(target_os = "windows")] +pub fn run_boundary( + config_path: &Path, + qualification: crate::RuntimeQualification, +) -> Result<(), String> { + windows::run_boundary(config_path, qualification) +} + +#[cfg(not(any(target_os = "linux", target_os = "windows")))] pub fn run_boundary( _config_path: &Path, _qualification: crate::RuntimeQualification, ) -> Result<(), String> { - Err("boundary mode is supported only on Linux".to_string()) + Err("boundary mode is supported only on Linux and Windows".to_string()) } diff --git a/crates/openshell-sandbox/src/boundary_server/windows.rs b/crates/openshell-sandbox/src/boundary_server/windows.rs new file mode 100644 index 0000000000..b00335894b --- /dev/null +++ b/crates/openshell-sandbox/src/boundary_server/windows.rs @@ -0,0 +1,1410 @@ +// 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 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, MxcSandboxAuditEvidence, OpenShellBoundaryAuditEvidence, 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(super) fn run_boundary( + config_path: &Path, + _qualification: crate::RuntimeQualification, +) -> 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)>, + 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())), + 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 { .. }) { + 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) { + if let Some(shutdown) = lock(&self.connection_shutdowns).remove(&id) { + 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(OpenShellBoundaryAuditEvidence::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 { + Request::Attach { + policy, + resource_claims, + .. + } => { + if resource_claims != &self.config.resource_claims { + guest_error(BoundaryErrorKind::Denied, "MXC resource claims mismatch") + } else { + 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") + } + } + } + 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 { + expected_revision, + revision, + provider_env, + } => { + let mut current = lock(&self.provider_environment); + if current.0 != *expected_revision || *revision <= *expected_revision { + guest_error( + BoundaryErrorKind::Denied, + "provider environment revision compare-and-swap failed", + ) + } else { + *current = (*revision, provider_env.clone()); + Response::ProviderEnvironmentUpdated { + revision: *revision, + } + } + } + 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, + .. + } = &envelope.request + else { + return guest_error(BoundaryErrorKind::Invalid, "expected StartAgent"); + }; + { + let lifecycle = *lock(&self.lifecycle); + if lifecycle == Lifecycle::Running { + if let Some(id) = lock(&self.main_process).clone() { + return Response::Started { + process_id: id, + provider_env_revision: lock(&self.provider_environment).0, + }; + } + } + 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(), + ca_bundle.as_deref(), + ) { + Ok(Some((certificate, bundle))) => { + environment.insert( + "NODE_EXTRA_CA_CERTS".to_string(), + certificate.display().to_string(), + ); + environment.insert("DENO_CERT".to_string(), certificate.display().to_string()); + for key in [ + "SSL_CERT_FILE", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "GIT_SSL_CAINFO", + ] { + environment.insert(key.to_string(), bundle.display().to_string()); + } + } + 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()); + *lock(&self.lifecycle) = Lifecycle::Running; + let response = Response::Started { + process_id, + provider_env_revision: *provider_env_revision, + }; + self.remember_replay(envelope, &response); + response + } + + async fn start_exec(&self, envelope: &RequestEnvelope, spec: ExecSpecWire) -> Response { + 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, + } + } + + 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; + } +} + +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}; + + // 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, &mut token) + .map_err(|error| format!("open MXC sandbox process token: {error}"))?; + let mut is_appcontainer = 0_u32; + let mut returned = 0_u32; + let query = GetTokenInformation( + token, + TokenIsAppContainer, + Some(std::ptr::from_mut(&mut is_appcontainer).cast()), + size_of_val(&is_appcontainer) as u32, + &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 != size_of_val(&is_appcontainer) as u32 { + 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 } => { + if !host.is_loopback() || port == 0 { + return write_response( + &mut stream, + &envelope.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, &envelope.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, + &envelope.request_id, + guest_error(BoundaryErrorKind::Process, error.to_string()), + ) + .await + } + } + } + _ => { + let response = runtime.dispatch(&envelope); + write_response(&mut stream, &envelope.request_id, response).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_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 { + if let Some(status) = *exit.borrow_and_update() { + 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 lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 4928fe6f40..ee2caed813 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -5,9 +5,12 @@ #[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; #[cfg(target_os = "linux")] pub(crate) mod delegated; @@ -15,6 +18,7 @@ pub(crate) mod delegated; pub mod identity; #[cfg(target_os = "linux")] pub mod main_session; +#[cfg(target_os = "linux")] pub mod managed_children; #[cfg(target_os = "linux")] mod network_broker; @@ -22,7 +26,9 @@ mod network_broker; pub mod perf; #[cfg(unix)] pub mod process; +#[cfg(target_os = "linux")] mod pty; +#[cfg(target_os = "linux")] pub mod sandbox; /// Results of actively qualifying the admitted workload runtime before the @@ -34,7 +40,7 @@ pub mod sandbox; reason = "qualification preserves independently exercised security results" )] pub struct RuntimeQualification { - pub seccomp: openshell_isolation_interface::contract::SeccompEvidence, + pub seccomp: openshell_sandbox_backend::boundary_protocol::SeccompEvidence, pub landlock_abi: u32, pub landlock_allow_deny: bool, pub udp_dns_round_trip: bool, diff --git a/crates/openshell-sandbox/src/main.rs b/crates/openshell-sandbox/src/main.rs index 24fa65ab16..9b55195e2c 100644 --- a/crates/openshell-sandbox/src/main.rs +++ b/crates/openshell-sandbox/src/main.rs @@ -9,11 +9,11 @@ use std::path::Path; use clap::Parser; use miette::{IntoDiagnostic, Result}; -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "windows"))] use openshell_ocsf::OcsfShorthandLayer; -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "windows"))] use tracing_subscriber::EnvFilter; -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "windows"))] use tracing_subscriber::{Layer, layer::SubscriberExt, util::SubscriberInitExt}; /// Subcommand name used to self-copy the sandbox binary into a shared volume. @@ -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"; @@ -239,7 +239,7 @@ fn qualify_runtime() -> Result<(openshell_sandbox::RuntimeQualification, Qualifi wait_killable_recv: notification.wait_killable_recv, }; let qualification = openshell_sandbox::RuntimeQualification { - seccomp: openshell_isolation_interface::contract::SeccompEvidence { + seccomp: openshell_sandbox_backend::boundary_protocol::SeccompEvidence { new_listener: notification.notification_round_trip(), notification_round_trip: notification.notification_round_trip(), id_validation: notification.notification_round_trip(), @@ -1624,7 +1624,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 _; @@ -1681,7 +1681,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, @@ -1699,7 +1699,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 _}; @@ -1736,10 +1736,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 _}; @@ -1792,6 +1794,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 _; @@ -1836,6 +1839,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 = @@ -1851,9 +1861,25 @@ fn run_boundary(bootstrap: &Path, log_level: &str) -> Result<()> { openshell_sandbox::run(bootstrap, qualification) } -#[cfg(not(target_os = "linux"))] +#[cfg(target_os = "windows")] +fn run_boundary(bootstrap: &Path, log_level: &str) -> Result<()> { + let console_filter = + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(log_level)); + let _ = tracing_subscriber::registry() + .with( + OcsfShorthandLayer::new(std::io::stderr()) + .with_non_ocsf(true) + .with_filter(console_filter), + ) + .try_init(); + openshell_sandbox::run(bootstrap, openshell_sandbox::RuntimeQualification) +} + +#[cfg(not(any(target_os = "linux", target_os = "windows")))] fn run_boundary(_bootstrap: &Path, _log_level: &str) -> Result<()> { - Err(miette::miette!("openshell-sandbox requires Linux")) + Err(miette::miette!( + "openshell-sandbox requires Linux or Windows" + )) } fn main() -> Result<()> { @@ -1903,7 +1929,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-sdk/src/client.rs b/crates/openshell-sdk/src/client.rs index 473f34d625..b14e5459e6 100644 --- a/crates/openshell-sdk/src/client.rs +++ b/crates/openshell-sdk/src/client.rs @@ -360,6 +360,7 @@ impl OpenShellClient { allow_missing: opts.allow_missing, name: name.to_string(), workspace_scope: Some(proto::workspace_selector("default")), + ..Default::default() }; async move { grpc.delete_sandbox(request).await } }) @@ -957,6 +958,7 @@ impl WorkspaceScopedClient { allow_missing: opts.allow_missing, name: name.to_string(), workspace_scope: Some(proto::workspace_selector(&self.workspace)), + ..Default::default() }; async move { grpc.delete_sandbox(request).await } }) diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 4cca02d6ac..1d36b8bbc5 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -36,9 +36,11 @@ use openshell_core::proto::compute::v1::{ watch_sandboxes_event, }; use openshell_core::proto::{ - PlatformEvent, Sandbox, SandboxCondition, SandboxPhase, SandboxSpec, SandboxStatus, - SandboxTemplate, SandboxWorkloadTemplate, ServiceEndpoint, SshSession, + PlatformEvent, Sandbox, SandboxCondition, SandboxPhase, SandboxPolicy as ProtoSandboxPolicy, + SandboxSpec, SandboxStatus, SandboxTemplate, SandboxWorkloadTemplate, ServiceEndpoint, + SshSession, }; +use openshell_core::provider_credentials::ProviderCredentialState; use openshell_core::telemetry::TelemetryComputeDriver; use openshell_core::{ObjectLabels, ObjectWorkspace}; use prost::Message; @@ -65,6 +67,62 @@ pub type DriverWatchStream = Pin> + Send>>; pub type SharedComputeDriver = Arc + Send + Sync>; +pub type SandboxProviderCredentialsSink = Arc>>; + +/// 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 provider_credentials: Option, + pub launch_authentication: Option>, +} + +impl SandboxCreateRuntimeInputs { + #[must_use] + pub(crate) fn new( + effective_policy: ProtoSandboxPolicy, + provider_credentials: Option, + ) -> Self { + Self { + effective_policy: Some(effective_policy), + provider_credentials, + launch_authentication: None, + } + } +} + +struct StagedProviderCredentials { + sink: SandboxProviderCredentialsSink, + sandbox_id: String, +} + +impl StagedProviderCredentials { + fn stage( + sink: SandboxProviderCredentialsSink, + sandbox_id: &str, + credentials: ProviderCredentialState, + ) -> Self { + let mut pending = sink + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + pending.insert(sandbox_id.to_string(), credentials); + drop(pending); + Self { + sink, + sandbox_id: sandbox_id.to_string(), + } + } +} + +impl Drop for StagedProviderCredentials { + fn drop(&mut self) { + self.sink + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&self.sandbox_id); + } +} use traced_driver::TracedDriver; @@ -270,6 +328,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 { @@ -307,6 +375,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, @@ -326,6 +395,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, } /// Interval between store-vs-backend reconciliation sweeps. @@ -616,6 +688,7 @@ pub struct ComputeRuntime { telemetry_compute_driver: TelemetryComputeDriver, driver_process: Option>, default_image: String, + provider_credentials_sink: Option, store: Arc, sandbox_index: SandboxIndex, sandbox_watch_bus: SandboxWatchBus, @@ -652,6 +725,7 @@ impl ComputeRuntime { driver_name: String, driver: SharedComputeDriver, driver_process: Option>, + provider_credentials_sink: Option, store: Arc, sandbox_index: SandboxIndex, sandbox_watch_bus: SandboxWatchBus, @@ -681,6 +755,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 gateway_listener_requirements = match driver @@ -748,6 +823,7 @@ impl ComputeRuntime { telemetry_compute_driver: TelemetryComputeDriver::custom(), driver_process, default_image, + provider_credentials_sink, store, sandbox_index, sandbox_watch_bus, @@ -799,6 +875,7 @@ impl ComputeRuntime { endpoint.name, driver, endpoint.driver_process, + None, store, sandbox_index, sandbox_watch_bus, @@ -813,6 +890,12 @@ impl ComputeRuntime { &self.default_image } + /// Whether this in-process driver accepts create-time provider state. + #[must_use] + pub(crate) fn accepts_create_time_provider_credentials(&self) -> bool { + self.provider_credentials_sink.is_some() + } + #[must_use] pub fn driver_info_snapshots(&self) -> &[ComputeDriverInfoSnapshot] { std::slice::from_ref(&self.driver_info) @@ -918,8 +1001,26 @@ 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_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) { @@ -942,17 +1043,35 @@ 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, sandbox_token: Option, await_main_process_attachment: bool, ) -> Result { - self.create_sandbox_authenticated( + self.create_sandbox_with_runtime_inputs( sandbox, sandbox_token, - None, await_main_process_attachment, + SandboxCreateRuntimeInputs::default(), ) .await } @@ -964,6 +1083,34 @@ impl ComputeRuntime { launch_authentication: Option>, await_main_process_attachment: bool, ) -> Result { + self.create_sandbox_with_runtime_inputs( + sandbox, + sandbox_token, + await_main_process_attachment, + SandboxCreateRuntimeInputs { + launch_authentication, + ..Default::default() + }, + ) + .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 { + // Defense in depth for internal callers that bypass the public create + // handler's ValidateSandboxCreate step. This check has no side effects. + self.validate_policy_capabilities(&sandbox, runtime_inputs.effective_policy.as_ref())?; + if runtime_inputs.provider_credentials.is_some() && self.provider_credentials_sink.is_none() + { + return Err(Status::internal( + "provider credentials supplied to a compute driver without a create-time sink", + )); + } let sandbox_id = sandbox.object_id().to_string(); let mut sandbox = sandbox; @@ -978,6 +1125,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()); } @@ -1026,8 +1178,20 @@ 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(); } + let _staged_provider_credentials = match ( + self.provider_credentials_sink.clone(), + runtime_inputs.provider_credentials, + ) { + (Some(sink), Some(credentials)) => Some(StagedProviderCredentials::stage( + sink, + &sandbox_id, + credentials, + )), + (None, Some(_)) => unreachable!("provider credential sink checked before persistence"), + (_, None) => None, + }; match self .driver .call( @@ -1578,8 +1742,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( @@ -1587,6 +1755,48 @@ impl ComputeRuntime { workspace: &str, 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 { // Resolve and acquire both request-side locks before spawning the // owned worker. Cancellation while any of these awaits is pending is @@ -1605,6 +1815,15 @@ 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(), @@ -1622,7 +1841,7 @@ impl ComputeRuntime { tokio::spawn( async move { runtime - .delete_sandbox_inner(target, delete_guard, global_guard) + .delete_sandbox_inner(target, preconditions, delete_guard, global_guard) .await } .instrument(request_span), @@ -1638,6 +1857,7 @@ impl ComputeRuntime { async fn delete_sandbox_inner( &self, target: SandboxDeleteTarget, + preconditions: SandboxDeletePreconditions, delete_guard: SandboxLifecycleGuard, guard: tokio::sync::OwnedMutexGuard<()>, ) -> Result { @@ -1661,6 +1881,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 @@ -5062,6 +5299,7 @@ impl ComputeDriver for NoopTestDriver { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_ui_policy: false, }, )) } @@ -5209,10 +5447,12 @@ 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, default_image: "openshell/sandbox:test".to_string(), + provider_credentials_sink: None, store, sandbox_index: SandboxIndex::new(), sandbox_watch_bus: SandboxWatchBus::new(), @@ -5235,6 +5475,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}; @@ -5514,6 +5755,11 @@ mod tests { listed_sandboxes: Vec, current_sandboxes: Vec, workspace_rpcs_unimplemented: bool, + validate_create_calls: AtomicUsize, + create_calls: AtomicUsize, + provider_credentials_sink: Option, + provider_env_at_create: TestMutex>>, + created_sandboxes: TestMutex>, } #[tonic::async_trait] @@ -5546,6 +5792,7 @@ mod tests { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_ui_policy: false, })) } @@ -5562,6 +5809,7 @@ mod tests { &self, _request: Request, ) -> Result, Status> { + self.validate_create_calls.fetch_add(1, Ordering::Relaxed); Ok(tonic::Response::new(ValidateSandboxCreateResponse {})) } @@ -5611,8 +5859,28 @@ 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 { + if let Some(sink) = &self.provider_credentials_sink { + let credentials = sink + .lock() + .expect("provider credential staging lock poisoned") + .get(&sandbox.id) + .cloned() + .expect("provider credentials must be staged before driver create"); + *self + .provider_env_at_create + .lock() + .expect("provider env observation lock poisoned") = + Some(credentials.child_env_with_gcp_resolved()); + } + self.created_sandboxes + .lock() + .expect("created sandbox observation lock poisoned") + .push(sandbox); + } Ok(tonic::Response::new(CreateSandboxResponse {})) } @@ -5899,6 +6167,7 @@ mod tests { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_ui_policy: false, })) } @@ -6116,10 +6385,12 @@ 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, default_image: "openshell/sandbox:test".to_string(), + provider_credentials_sink: None, store, sandbox_index: SandboxIndex::new(), sandbox_watch_bus: SandboxWatchBus::new(), @@ -6133,6 +6404,200 @@ 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() + }, + None, + ); + + 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 sink: SandboxProviderCredentialsSink = Arc::new(TestMutex::new(HashMap::new())); + let driver = Arc::new(TestDriver { + provider_credentials_sink: Some(sink.clone()), + ..Default::default() + }); + let mut runtime = test_runtime(driver.clone()).await; + runtime.provider_credentials_sink = Some(sink.clone()); + + 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 provider_credentials = ProviderCredentialState::from_environment( + 17, + HashMap::from([("GITHUB_TOKEN".to_string(), "raw-test-token".to_string())]), + HashMap::new(), + HashMap::new(), + ); + let runtime_inputs = SandboxCreateRuntimeInputs::new( + PublicSandboxPolicy { + version: 2, + ..Default::default() + }, + Some(provider_credentials), + ); + + runtime + .create_sandbox_with_runtime_inputs(sandbox, None, false, runtime_inputs) + .await + .expect("create should succeed"); + + let observed_env = driver + .provider_env_at_create + .lock() + .expect("provider env observation lock poisoned") + .clone() + .expect("driver should observe staged provider credentials"); + let observed_token = &observed_env["GITHUB_TOKEN"]; + assert_ne!(observed_token, "raw-test-token"); + assert!(observed_token.starts_with(openshell_core::secrets::PLACEHOLDER_PREFIX_PUBLIC)); + assert!( + sink.lock() + .expect("provider credential staging lock poisoned") + .is_empty(), + "create-time credentials must be removed after the driver call" + ); + + let observed_policy_version = { + let created = driver + .created_sandboxes + .lock() + .expect("created sandbox observation lock poisoned"); + 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_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, @@ -8942,6 +9407,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(); @@ -10524,6 +11116,7 @@ mod tests { }), workspace: "default".to_string(), }], + ..Default::default() })) .await; @@ -10695,6 +11288,7 @@ mod tests { })), workspace: "default".to_string(), }], + ..Default::default() })) .await; @@ -11520,6 +12114,7 @@ mod tests { "test-driver".to_string(), Arc::new(TestDriver::default()), None, + None, store, SandboxIndex::new(), SandboxWatchBus::new(), diff --git a/crates/openshell-server/src/credentials.rs b/crates/openshell-server/src/credentials.rs index 6098357f01..1b82ff4b6b 100644 --- a/crates/openshell-server/src/credentials.rs +++ b/crates/openshell-server/src/credentials.rs @@ -107,6 +107,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)] @@ -712,6 +716,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 c0f6f926d5..25a750615b 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -250,6 +250,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/mutation_replay/ordinary/tests.rs b/crates/openshell-server/src/grpc/mutation_replay/ordinary/tests.rs index 653b0de47b..893cddc9e7 100644 --- a/crates/openshell-server/src/grpc/mutation_replay/ordinary/tests.rs +++ b/crates/openshell-server/src/grpc/mutation_replay/ordinary/tests.rs @@ -48,6 +48,8 @@ pub(in crate::grpc::mutation_replay) async fn exercise_protected_backend(url: &s workspace_scope: Some(scope()), allow_missing: true, request_id: id(), + expected_sandbox_id: String::new(), + expected_resource_version: 0, }; let mut tasks = Vec::new(); for index in 0..16 { @@ -281,6 +283,8 @@ async fn keyed_fingerprints_fail_closed_on_missing_or_rotated_keys() { workspace_scope: Some(scope()), allow_missing: true, request_id: id(), + expected_sandbox_id: String::new(), + expected_resource_version: 0, }; assert_eq!( reason( @@ -328,6 +332,8 @@ async fn original_payload_is_identity_and_current_transformation_is_a_replay_gua workspace_scope: Some(scope()), allow_missing: true, request_id: id(), + expected_sandbox_id: String::new(), + expected_resource_version: 0, }; let mut effective = original.clone(); effective.name = "transformed".into(); diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 4a65520827..6a5bd7a591 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -1716,6 +1716,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 @@ -1732,20 +1746,40 @@ async fn current_effective_policy_for_sandbox( .as_ref() .map(|spec| spec.providers.clone()) .unwrap_or_default(); + let provider_records = super::provider::load_provider_environment_records( + state.store.as_ref(), + workspace, + &provider_names, + ) + .await?; + current_effective_policy_for_sandbox_with_records( + state, + catalog, + sandbox, + sandbox_id, + &provider_records, + ) + .await +} + +async fn current_effective_policy_for_sandbox_with_records( + state: &ServerState, + catalog: &EffectiveProviderProfileCatalog, + sandbox: &Sandbox, + sandbox_id: &str, + provider_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)? { - // 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. - return apply_effective_policy_context( - state, + if let Some(mut global_policy) = decode_policy_from_global_settings(&global_settings)? { + // A global policy replaces dynamic policy, but startup-only UI remains + // anchored to the sandbox spec so reads cannot misrepresent enforcement. + preserve_sandbox_startup_ui(&mut global_policy, sandbox); + return apply_effective_policy_context_from_records( catalog, - workspace, - &provider_names, + provider_records, global_policy, PolicySource::Global, - ) - .await; + ); } let policy = if let Some(record) = state @@ -1764,15 +1798,12 @@ async fn current_effective_policy_for_sandbox( } }; - apply_effective_policy_context( - state, + apply_effective_policy_context_from_records( catalog, - workspace, - &provider_names, + provider_records, policy, PolicySource::Sandbox, ) - .await } async fn effective_policy_for_source( @@ -1807,17 +1838,26 @@ async fn apply_effective_policy_context( catalog: &EffectiveProviderProfileCatalog, workspace: &str, provider_names: &[String], - mut policy: ProtoSandboxPolicy, + policy: ProtoSandboxPolicy, policy_source: PolicySource, ) -> Result { - clear_provider_credentialed_markers(&mut policy); - let mut provider_context = provider_policy_context_with_catalog( + let provider_records = super::provider::load_provider_environment_records( state.store.as_ref(), - catalog, workspace, provider_names, ) .await?; + apply_effective_policy_context_from_records(catalog, &provider_records, policy, policy_source) +} + +fn apply_effective_policy_context_from_records( + catalog: &EffectiveProviderProfileCatalog, + provider_records: &[super::provider::ProviderEnvironmentRecord], + mut policy: ProtoSandboxPolicy, + policy_source: PolicySource, +) -> Result { + clear_provider_credentialed_markers(&mut policy); + let mut provider_context = provider_policy_context_from_records(catalog, provider_records); if !matches!(policy_source, PolicySource::Global) && !provider_context.layers.is_empty() { policy = compose_effective_policy(&policy, &provider_context.layers); } @@ -2523,9 +2563,10 @@ pub(super) async fn handle_get_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)) @@ -2991,16 +3032,23 @@ async fn provider_policy_context_with_catalog( workspace: &str, provider_names: &[String], ) -> Result { + let records = + super::provider::load_provider_environment_records(store, workspace, provider_names) + .await?; + Ok(provider_policy_context_from_records(catalog, &records)) +} + +fn provider_policy_context_from_records( + catalog: &EffectiveProviderProfileCatalog, + records: &[super::provider::ProviderEnvironmentRecord], +) -> ProviderPolicyContext { let mut layers = Vec::new(); let mut credentialed_scopes = Vec::new(); let mut endpointless_provider_names = HashSet::new(); - for name in provider_names { - let provider = store - .get_message_by_name::(workspace, name) - .await - .map_err(|e| Status::internal(format!("failed to fetch provider '{name}': {e}")))? - .ok_or_else(|| Status::failed_precondition(format!("provider '{name}' not found")))?; + for record in records { + let name = &record.name; + let provider = &record.provider; let provider_type = provider.r#type.trim(); let Some(profile) = super::provider::get_provider_type_profile_for_scope( @@ -3016,7 +3064,7 @@ async fn provider_policy_context_with_catalog( continue; }; - if !super::provider::provider_profile_endpoints_are_active(&profile, &provider) { + if !super::provider::provider_profile_endpoints_are_active(&profile, provider) { endpointless_provider_names.insert(name.clone()); continue; } @@ -3045,11 +3093,11 @@ async fn provider_policy_context_with_catalog( }); } - Ok(ProviderPolicyContext { + ProviderPolicyContext { layers, credentialed_scopes, endpointless_provider_names, - }) + } } fn endpoint_ports(endpoint: &NetworkEndpoint) -> Vec { @@ -3230,21 +3278,169 @@ pub(super) async fn handle_get_gateway_config( })) } +/// Resolve the effective policy and provider credential snapshot required by +/// an in-process compute driver at sandbox creation time. +/// +/// The policy, revision, endpoint bindings, and environment are all derived +/// from one immutable provider-record snapshot. Raw static credentials remain +/// in the returned resolver state; only revision-scoped placeholders are +/// exposed through its child environment. +pub(super) async fn resolve_sandbox_create_runtime_inputs( + state: &ServerState, + sandbox: &Sandbox, +) -> Result { + if !state.compute.accepts_create_time_provider_credentials() { + return Ok(crate::compute::SandboxCreateRuntimeInputs::default()); + } + + 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_for_sandbox_with_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, + )?; + let provider_env_revision = compute_provider_env_revision_from_records_and_policy_bindings( + &provider_profile_catalog, + &provider_records, + &policy_credential_bindings, + )?; + let mut provider_environment = + super::provider::resolve_provider_environment_from_records_with_policy_bindings_and_credentials( + state.store.as_ref(), + &provider_profile_catalog, + &provider_records, + &policy_credential_bindings, + &state.credentials, + Some(sandbox_id), + ) + .await?; + + // MXC uses the binding-capable host proxy. Withhold any static value that + // has no endpoint binding instead of exposing it directly to the process. + let unbound_static_keys = provider_environment + .static_credential_keys + .iter() + .filter(|key| { + !provider_environment + .static_credential_bindings + .contains_key(*key) + }) + .cloned() + .collect::>(); + for key in unbound_static_keys { + warn!( + sandbox_id, + key = %key, + "withholding unbound static provider credential from MXC sandbox" + ); + provider_environment.environment.remove(&key); + provider_environment + .credential_expiration_times + .remove(&key); + provider_environment.static_credential_keys.remove(&key); + } + validate_create_time_provider_credential_lifetimes(sandbox_id, &provider_environment)?; + + let provider_credentials = if provider_records.is_empty() { + None + } else { + let non_secret_environment_keys = provider_environment + .environment + .keys() + .filter(|key| !provider_environment.static_credential_keys.contains(*key)) + .cloned() + .collect(); + Some( + openshell_core::provider_credentials::ProviderCredentialState::from_bound_environment( + provider_env_revision, + provider_environment.environment, + provider_environment.credential_expiration_times, + provider_environment.dynamic_credentials, + provider_environment.static_credential_bindings, + non_secret_environment_keys, + ) + .map_err(|error| { + Status::failed_precondition(format!( + "invalid provider credential binding for sandbox '{sandbox_id}': {error}" + )) + })?, + ) + }; + + Ok(crate::compute::SandboxCreateRuntimeInputs::new( + effective_policy, + provider_credentials, + )) +} + +fn validate_create_time_provider_credential_lifetimes( + sandbox_id: &str, + provider_environment: &super::provider::ProviderEnvironment, +) -> Result<(), Status> { + let mut expiring_static_keys = provider_environment + .static_credential_keys + .iter() + .filter(|key| { + provider_environment + .credential_expiration_times + .get(*key) + .is_some_and(|expires_at_ms| *expires_at_ms > 0) + }) + .chain(provider_environment.expired_static_keys.iter()) + .cloned() + .collect::>() + .into_iter() + .collect::>(); + expiring_static_keys.sort(); + + if expiring_static_keys.is_empty() { + Ok(()) + } else { + Err(Status::failed_precondition(format!( + "compute driver cannot refresh expiring or already-expired provider credentials for sandbox '{sandbox_id}'; recreate the sandbox with non-expiring, current credentials (affected keys: {})", + expiring_static_keys.join(", ") + ))) + } +} + 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 workspace = sandbox.object_workspace().to_string(); let spec = sandbox @@ -3263,12 +3459,12 @@ pub(super) async fn handle_get_sandbox_provider_environment( &provider_names, ) .await?; - let effective_policy = current_effective_policy_for_sandbox( + let effective_policy = current_effective_policy_for_sandbox_with_records( state.as_ref(), &provider_profile_catalog, - &workspace, &sandbox, &sandbox_id, + &provider_records, ) .await?; let policy_credential_bindings = @@ -3479,6 +3675,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?; @@ -3884,7 +4085,10 @@ async fn handle_update_config_inner( } let should_backfill_policy = if let Some(baseline_policy) = spec.policy.as_ref() { - let comparable_baseline = baseline_policy.clone(); + let comparable_baseline = validate_and_canonicalize_stored_policy( + baseline_policy.clone(), + STORED_POLICY_SOURCE_SPEC, + )?; validate_static_fields_unchanged(&comparable_baseline, &new_policy)?; false } else { @@ -6586,17 +6790,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 { @@ -6607,12 +6811,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(), @@ -7223,6 +7421,7 @@ mod tests { use crate::auth::principal::{ Principal, SandboxIdentitySource, SandboxPrincipal, UserPrincipal, }; + use crate::grpc::provider::ProviderEnvironment; use crate::grpc::test_support::{authed_request, test_server_state}; use crate::persistence::test_store; use std::collections::HashMap; @@ -8140,6 +8339,66 @@ 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 { + sandbox_id: sandbox_id.to_string(), + }), + 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; @@ -8237,6 +8496,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 { + name: 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() { @@ -11667,6 +12013,63 @@ mod tests { assert_eq!(v2_env.get("GITHUB_TOKEN"), Some(&"ghp-test".to_string())); } + #[test] + fn create_time_provider_credentials_reject_expiring_static_values() { + let provider_environment = ProviderEnvironment { + credential_expiration_times: HashMap::from([ + ("B_TOKEN".to_string(), 20_000), + ("A_TOKEN".to_string(), 10_000), + ("NON_SECRET".to_string(), 30_000), + ]), + static_credential_keys: HashSet::from(["A_TOKEN".to_string(), "B_TOKEN".to_string()]), + ..Default::default() + }; + + let error = validate_create_time_provider_credential_lifetimes( + "sandbox-expiring", + &provider_environment, + ) + .expect_err("expiring static credentials must fail closed"); + + assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error.message().contains("A_TOKEN, B_TOKEN")); + assert!(!error.message().contains("NON_SECRET")); + } + + #[test] + fn create_time_provider_credentials_allow_non_expiring_static_values() { + let provider_environment = ProviderEnvironment { + credential_expiration_times: HashMap::from([("STATIC_TOKEN".to_string(), 0)]), + static_credential_keys: HashSet::from(["STATIC_TOKEN".to_string()]), + ..Default::default() + }; + + validate_create_time_provider_credential_lifetimes("sandbox-static", &provider_environment) + .expect("non-expiring static credentials are supported"); + } + + #[test] + fn create_time_provider_credentials_reject_already_expired_static_values() { + // The shared resolver withholds already-expired static credentials + // entirely -- they never appear in `static_credential_keys` or + // `credential_expires_at_ms` -- so this check must consult + // `expired_static_keys` independently instead of silently allowing + // sandbox creation without the configured credential. + let provider_environment = ProviderEnvironment { + expired_static_keys: HashSet::from(["GITHUB_TOKEN".to_string()]), + ..Default::default() + }; + + let error = validate_create_time_provider_credential_lifetimes( + "sandbox-expired", + &provider_environment, + ) + .expect_err("already-expired static credentials must fail closed"); + + assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error.message().contains("GITHUB_TOKEN")); + } + #[tokio::test] async fn provider_environment_withholds_static_credentials_from_legacy_supervisors() { use openshell_core::proto::GetSandboxProviderEnvironmentRequest; @@ -18895,6 +19298,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}; @@ -21296,6 +21728,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 f2a193e109..13ed8f9769 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -71,6 +71,13 @@ pub(super) struct ProviderEnvironment { pub dynamic_credentials: HashMap, pub static_credential_bindings: HashMap, pub static_credential_keys: HashSet, + /// 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 so create-time + /// callers (see `validate_create_time_provider_credential_lifetimes`) can + /// fail closed instead of silently creating a sandbox without the + /// configured credential. + pub expired_static_keys: HashSet, } /// Immutable provider records used to build one provider-environment response. @@ -1128,6 +1135,7 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin let mut expires = HashMap::new(); let mut static_credential_bindings = HashMap::new(); let mut static_credential_keys = HashSet::new(); + 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(); @@ -1248,6 +1256,7 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin expires_at_ms, "skipping expired provider credential" ); + expired_static_keys.insert(key.clone()); continue; } expires.entry(key.clone()).or_insert(expires_at_ms); @@ -1345,6 +1354,23 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin } } + // The credential runtime withholds expired handle-backed values. Keep + // the identity of any otherwise injectable key so MXC create-time + // validation can reject the incomplete credential snapshot. + 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 @@ -1361,6 +1387,7 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin dynamic_credentials: resolve_dynamic_credentials_from_records(catalog, records), static_credential_bindings, static_credential_keys, + expired_static_keys, }) } @@ -10979,6 +11006,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 ea470a7408..9331cb2289 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_selector, }; +use crate::compute::SandboxDeletePreconditions; use crate::pagination::Pagination; use crate::persistence::{ ObjectLabels, ObjectListQuery, ObjectType, WriteCondition, generate_name, @@ -492,9 +493,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"); @@ -530,14 +534,15 @@ 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 = state .compute - .create_sandbox_authenticated( + .create_sandbox_with_runtime_inputs( sandbox, sandbox_token, - launch_authentication, await_main_process_attachment, + runtime_inputs, ) .await?; @@ -1348,10 +1353,26 @@ async fn handle_delete_sandbox_inner( let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) .await? .name; + if req.expected_resource_version != 0 && req.expected_sandbox_id.is_empty() { + return Err(Status::invalid_argument( + "expected_resource_version requires expected_sandbox_id", + )); + } + let preconditions = SandboxDeletePreconditions { + expected_sandbox_id: (!req.expected_sandbox_id.is_empty()) + .then_some(req.expected_sandbox_id), + expected_resource_version: (req.expected_resource_version != 0) + .then_some(req.expected_resource_version), + }; 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); @@ -2019,6 +2040,8 @@ pub(super) async fn handle_forward_tcp( } let connection_guard = acquire_forward_connection_guard(state, &init, &sandbox).await?; + let sandbox_id = sandbox.object_id().to_string(); + let (channel_id, relay_rx) = state .supervisor_sessions .open_relay_with_target( @@ -2030,7 +2053,6 @@ 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); tokio::spawn(async move { let _connection_guard = connection_guard; @@ -2218,13 +2240,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(); @@ -3766,6 +3790,7 @@ mod tests { workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), + ..Default::default() }), ) .await @@ -3807,6 +3832,70 @@ mod tests { ); } + #[tokio::test] + async fn delete_handler_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 = handle_delete_sandbox_inner( + &state, + authed_request(DeleteSandboxRequest { + allow_missing: false, + name: "guarded-delete".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + expected_sandbox_id: "sb-stale".to_string(), + expected_resource_version: 0, + request_id: String::new(), + }), + ) + .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 delete_handler_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 = handle_delete_sandbox_inner( + &state, + authed_request(DeleteSandboxRequest { + allow_missing: false, + name: "guarded-delete".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + expected_sandbox_id: String::new(), + expected_resource_version: 17, + request_id: String::new(), + }), + ) + .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; @@ -6868,6 +6957,7 @@ mod tests { allow_missing: false, workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), name: "any".into(), + ..Default::default() }), ) .await diff --git a/crates/openshell-server/src/grpc/validation.rs b/crates/openshell-server/src/grpc/validation.rs index 8a81e2648c..76e3be2af2 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, - SandboxSpec, SandboxTemplate, + SandboxSpec, SandboxTemplate, UiClipboardAccess, }; use openshell_core::rpc_error::invalid_argument; use prost::Message; @@ -1015,7 +1015,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, @@ -1039,6 +1039,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(()) } @@ -2344,6 +2356,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/lib.rs b/crates/openshell-server/src/lib.rs index d68c93c6b6..6a7cb1daf9 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -1098,13 +1098,19 @@ async fn terminate_signal() { } pub use compute::{ - AcquiredRemoteDriverEndpoint, DriverWatchStream, ManagedDriverProcess, SharedComputeDriver, + AcquiredRemoteDriverEndpoint, DriverWatchStream, ManagedDriverProcess, + SandboxProviderCredentialsSink, SharedComputeDriver, }; /// Driver instance returned by a compiled compute-driver factory. pub enum ComputeDriverInstance { /// A driver hosted in the gateway process. InProcess(SharedComputeDriver), + /// An in-process driver with a create-time provider credential side channel. + InProcessWithProviderCredentials { + driver: SharedComputeDriver, + provider_credentials_sink: SandboxProviderCredentialsSink, + }, /// A driver process launched and owned by the gateway. ManagedRemote(AcquiredRemoteDriverEndpoint), } @@ -1549,6 +1555,25 @@ async fn build_compute_runtime( registration.name, driver, None, + None, + store, + sandbox_index, + sandbox_watch_bus, + tracing_log_bus, + supervisor_sessions, + ) + .await + .map_err(|error| { + Error::execution(format!("failed to create compute runtime: {error}")) + })?, + ComputeDriverInstance::InProcessWithProviderCredentials { + driver, + provider_credentials_sink, + } => ComputeRuntime::from_driver( + registration.name, + driver, + None, + Some(provider_credentials_sink), store, sandbox_index, sandbox_watch_bus, diff --git a/crates/openshell-server/src/storage_proto.rs b/crates/openshell-server/src/storage_proto.rs index 5d168322e4..b0e23a3074 100644 --- a/crates/openshell-server/src/storage_proto.rs +++ b/crates/openshell-server/src/storage_proto.rs @@ -118,11 +118,11 @@ mod tests { const STORAGE_V1_SCHEMA_SHA256: &str = "574bf5fcff731bd6e3fd84ed3f124161035bd236ef0fb7e32b4d8a8c55ceba5e"; const PUBLIC_RPC_SCHEMA_SHA256: &str = - "1c72f65167a5ceb00cd17043079b92324ca55d19b547c86827b51bd7376cb23c"; + "8c937251144aea63261d4d96f41122291f45b75710e0a0742ae9bc23ee184590"; const DURABLE_SCHEMA_SHA256: &str = - "65066c0b0eef57a4c708f20fcbbb8e8f47376da9f4bf73dfc3bca0b3df174ba8"; + "d665d84ca16d663b312cb6c469613821375453014e43b38cbd8c656b3993b3f0"; const PUBLIC_DURABLE_OVERLAP_SHA256: &str = - "39e8aaf0d1fbc86906c49a9e7f60641a3ce203d3130799c8065e09acf9d53ddf"; + "b54aa96237c5e16fb5c933f5986774990d1972b232e246f47864fe495c2dedab"; // 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"; @@ -535,13 +535,13 @@ mod tests { assert_eq!( (public_closure.messages.len(), public_closure.enums.len()), - (283, 14) + (284, 15) ); assert_eq!( (durable_closure.messages.len(), durable_closure.enums.len()), - (83, 9) + (84, 10) ); - assert_eq!((overlap_messages.len(), overlap_enums.len()), (73, 9)); + assert_eq!((overlap_messages.len(), overlap_enums.len()), (74, 10)); assert_eq!( public_inventory_hash, PUBLIC_RPC_SCHEMA_SHA256, @@ -620,6 +620,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 aa5d86b798..581efc2c85 100644 --- a/crates/openshell-server/src/test_support.rs +++ b/crates/openshell-server/src/test_support.rs @@ -159,6 +159,7 @@ impl FakeComputeDriver { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_ui_policy: false, }, gateway_listener_requirements: Vec::new(), gateway_listener_requirements_supported: true, diff --git a/crates/openshell-supervisor-network/src/host.rs b/crates/openshell-supervisor-network/src/host.rs index f52ec29fb8..9ccc25cfe3 100644 --- a/crates/openshell-supervisor-network/src/host.rs +++ b/crates/openshell-supervisor-network/src/host.rs @@ -14,6 +14,7 @@ use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicU32; +use base64::Engine as _; use miette::Result; use openshell_core::activity::ActivitySender; use openshell_core::denial::DenialEvent; @@ -36,6 +37,27 @@ use crate::opa::OpaEngine; use crate::policy_local::PolicyLocalContext; use crate::proxy::ProxyHandle; +/// Ephemeral credential required from a sandbox before the host proxy will +/// evaluate or forward its request. +/// +/// The expected header value is intentionally private and this type does not +/// implement `Debug`, preventing accidental credential disclosure in logs. +#[derive(Clone)] +pub struct HostProxyClientAuth { + expected_proxy_authorization: Arc, +} + +impl HostProxyClientAuth { + #[must_use] + pub fn basic(username: &str, password: &str) -> Self { + let encoded = base64::engine::general_purpose::STANDARD + .encode(format!("{username}:{password}").as_bytes()); + Self { + expected_proxy_authorization: Arc::from(format!("Basic {encoded}")), + } + } +} + /// Configuration for a host-side `OpenShell` CONNECT proxy. pub struct HostProxyConfig { /// Exact socket the compute driver will redirect sandbox egress to. @@ -46,6 +68,9 @@ pub struct HostProxyConfig { /// socket-owning sandbox process. Policy binaries must match this path for /// L4/L7 allow rules to pass. pub binary_path: PathBuf, + /// Per-sandbox client authentication. Host-side MXC proxies must set this + /// so another sandbox cannot borrow this proxy's identity and policy. + pub client_auth: HostProxyClientAuth, pub sandbox_id: Option, pub sandbox_name: Option, pub openshell_endpoint: Option, @@ -220,6 +245,7 @@ pub async fn start_host_proxy(config: HostProxyConfig) -> Result String { + let mut request = String::from( + "GET http://policy.local/v1/policy/current HTTP/1.1\r\nHost: policy.local\r\n", + ); + for header in headers { + request.push_str(header); + request.push_str("\r\n"); + } + request.push_str("Connection: close\r\n\r\n"); + + let mut client = TcpStream::connect(addr).await.unwrap(); + client.write_all(request.as_bytes()).await.unwrap(); + let mut response = Vec::new(); + tokio::time::timeout(Duration::from_secs(2), client.read_to_end(&mut response)) + .await + .unwrap() + .unwrap(); + String::from_utf8(response).unwrap() + } + + async fn proxy_connect_request(addr: SocketAddr, headers: &[&str]) -> String { + let mut request = + String::from("CONNECT example.invalid:443 HTTP/1.1\r\nHost: example.invalid:443\r\n"); + for header in headers { + request.push_str(header); + request.push_str("\r\n"); + } + request.push_str("Connection: close\r\n\r\n"); + + let mut client = TcpStream::connect(addr).await.unwrap(); + client.write_all(request.as_bytes()).await.unwrap(); + let mut response = Vec::new(); + tokio::time::timeout(Duration::from_secs(2), client.read_to_end(&mut response)) + .await + .unwrap() + .unwrap(); + String::from_utf8(response).unwrap() + } + #[tokio::test] async fn rejects_non_loopback_bind_addr() { let result = start_host_proxy(test_config( @@ -340,24 +406,9 @@ mod tests { .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(); + let auth = HostProxyClientAuth::basic("openshell", "test-secret"); + let header = format!("Proxy-Authorization: {}", auth.expected_proxy_authorization); + let response = proxy_request(addr, &[&header]).await; assert!( response.starts_with("HTTP/1.1 200 OK"), "unexpected response: {response}" @@ -373,4 +424,68 @@ mod tests { "unexpected policy payload: {body}" ); } + + #[tokio::test] + async fn per_sandbox_credentials_reject_missing_wrong_cross_and_duplicate_auth() { + let binary = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(binary.path(), b"agent").unwrap(); + + let auth_a = HostProxyClientAuth::basic("openshell", "sandbox-a-secret"); + let auth_b = HostProxyClientAuth::basic("openshell", "sandbox-b-secret"); + // Node's EnvHttpProxyAgent currently emits the field name in lower + // case; HTTP field names are case-insensitive. + let header_a = format!( + "proxy-authorization: {}", + auth_a.expected_proxy_authorization + ); + let header_b = format!( + "Proxy-Authorization: {}", + auth_b.expected_proxy_authorization + ); + + let mut config_a = test_config(([127, 0, 0, 1], 0).into(), binary.path().to_path_buf()); + config_a.client_auth = auth_a; + let proxy_a = start_host_proxy(config_a).await.unwrap(); + + let mut config_b = test_config(([127, 0, 0, 1], 0).into(), binary.path().to_path_buf()); + config_b.client_auth = auth_b; + let proxy_b = start_host_proxy(config_b).await.unwrap(); + + let addr_a = proxy_a.http_addr().unwrap(); + let addr_b = proxy_b.http_addr().unwrap(); + assert!(proxy_request(addr_a, &[]).await.starts_with("HTTP/1.1 407")); + assert!( + proxy_request(addr_a, &[&header_b]) + .await + .starts_with("HTTP/1.1 407"), + "sandbox B credential must not authenticate to sandbox A proxy" + ); + assert!( + proxy_request(addr_a, &[&header_a, &header_a]) + .await + .starts_with("HTTP/1.1 407"), + "duplicate credentials must fail closed" + ); + assert!( + proxy_request(addr_a, &[&header_a]) + .await + .starts_with("HTTP/1.1 200") + ); + assert!( + proxy_request(addr_b, &[&header_b]) + .await + .starts_with("HTTP/1.1 200") + ); + assert!( + proxy_connect_request(addr_a, &[&header_b]) + .await + .starts_with("HTTP/1.1 407") + ); + assert!( + proxy_connect_request(addr_a, &[&header_a]) + .await + .starts_with("HTTP/1.1 403"), + "valid credentials must pass the auth gate and reach deny-by-default policy evaluation" + ); + } } diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index a2237d5e4e..e88b0fe8f1 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -1484,6 +1484,7 @@ fn validate_opa_object_array<'a>( 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 { .. } @@ -3282,6 +3283,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(), } @@ -4988,6 +4990,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"); @@ -5526,6 +5529,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -5597,6 +5601,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -5673,6 +5678,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -7214,6 +7220,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -7271,6 +7278,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -7329,6 +7337,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -7389,6 +7398,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -7448,6 +7458,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -8959,6 +8970,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 { @@ -9029,6 +9041,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"); @@ -9259,6 +9272,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).unwrap(); // Port 443 @@ -10227,6 +10241,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 @@ -10872,6 +10887,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/) @@ -10947,6 +10963,7 @@ network_policies: }), 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 46c8f69cea..9771afb875 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -222,6 +222,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 @@ -238,6 +239,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 { @@ -450,6 +456,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( @@ -473,6 +480,7 @@ impl ProxyHandle { dtx, atx, endpoint_observations, + required_authorization, ) .await { @@ -1274,6 +1282,8 @@ enum AcceptAction { }, } +// The resource-pressure counter is used only by the Unix errno classifier. +#[cfg_attr(not(unix), allow(clippy::needless_pass_by_ref_mut))] fn classify_accept_error( err: &std::io::Error, consecutive_resource_errors: &mut u32, @@ -1281,7 +1291,6 @@ fn classify_accept_error( ) -> AcceptAction { #[cfg(not(unix))] let _ = (err, &mut *consecutive_resource_errors); - #[cfg(unix)] if matches!( err.raw_os_error(), @@ -2024,6 +2033,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. @@ -2076,6 +2119,7 @@ async fn handle_tcp_connection( denial_tx, activity_tx, endpoint_observation_tx, + None, )) .await } @@ -2147,6 +2191,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. @@ -2209,6 +2254,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(); @@ -6853,6 +6912,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 024185d1ac..b67b53b595 100644 --- a/crates/openshell-supervisor-network/src/run.rs +++ b/crates/openshell-supervisor-network/src/run.rs @@ -203,6 +203,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 @@ -469,10 +470,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, @@ -493,7 +499,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-process/src/delegated.rs b/crates/openshell-supervisor-process/src/delegated.rs index dde9a7812e..0b7e465da3 100644 --- a/crates/openshell-supervisor-process/src/delegated.rs +++ b/crates/openshell-supervisor-process/src/delegated.rs @@ -11,8 +11,10 @@ use miette::Result; use openshell_isolation_interface::contract::{ BoundaryExec, BoundaryLoopbackConnector, BoundaryProcess, }; +#[cfg(unix)] use openshell_ocsf::{ActivityId, AppLifecycleBuilder, SeverityId, StatusId, ocsf_emit}; +#[cfg(unix)] fn ocsf_ctx() -> &'static openshell_ocsf::EventContext { openshell_ocsf::ctx::ctx() } @@ -77,6 +79,7 @@ impl Drop for BoundaryAccess { /// Start the supervisor access plane using sandbox-supplied exec and /// loopback-forwarding capabilities. #[allow(clippy::too_many_arguments)] +#[cfg_attr(not(unix), allow(unused_variables))] pub async fn start_boundary_access( sandbox_id: Option<&str>, openshell_endpoint: Option<&str>, @@ -100,105 +103,114 @@ pub async fn start_boundary_access( main_session: None, }); }; + #[cfg(not(unix))] + return Err(miette::miette!( + "SSH access sockets are unsupported by the Windows supervisor" + )); - let attachment = agent - .attach() - .await - .map_err(|error| miette::miette!(error.to_string()))?; - let main_session = crate::main_session::MainSession::from_boundary(attachment, agent); + #[cfg(unix)] + { + let attachment = agent + .attach() + .await + .map_err(|error| miette::miette!(error.to_string()))?; + let main_session = crate::main_session::MainSession::from_boundary(attachment, agent); - let (ssh_ready_tx, ssh_ready_rx) = tokio::sync::oneshot::channel(); - let listen_path = ssh_socket_path.clone(); - let ssh_port_forward = port_forward.clone(); - let ssh_main_session = main_session.clone(); - let ssh_task = tokio::spawn(async move { - if let Err(error) = crate::ssh::run_ssh_server( - listen_path, - ssh_ready_tx, - ca_file_paths, - shared_ssh_socket, - ssh_port_forward, - boundary_exec, - Some(ssh_main_session), - ) - .await - { - ocsf_emit!( - AppLifecycleBuilder::new(ocsf_ctx()) - .activity(ActivityId::Fail) - .severity(SeverityId::Critical) - .status(StatusId::Failure) - .message(format!("SSH server failed: {error}")) - .build() - ); - } - }); + let (ssh_ready_tx, ssh_ready_rx) = tokio::sync::oneshot::channel(); + let listen_path = ssh_socket_path.clone(); + let ssh_port_forward = port_forward.clone(); + let ssh_main_session = main_session.clone(); + let ssh_task = tokio::spawn(async move { + if let Err(error) = crate::ssh::run_ssh_server( + listen_path, + ssh_ready_tx, + ca_file_paths, + shared_ssh_socket, + ssh_port_forward, + boundary_exec, + Some(ssh_main_session), + ) + .await + { + ocsf_emit!( + AppLifecycleBuilder::new(ocsf_ctx()) + .activity(ActivityId::Fail) + .severity(SeverityId::Critical) + .status(StatusId::Failure) + .message(format!("SSH server failed: {error}")) + .build() + ); + } + }); - match tokio::time::timeout(Duration::from_secs(10), ssh_ready_rx).await { - Ok(Ok(Ok(()))) => {} - Ok(Ok(Err(error))) => { - ssh_task.abort(); - return Err(error.context("SSH server failed during startup")); - } - Ok(Err(_)) => { - ssh_task.abort(); - return Err(miette::miette!( - "SSH server task ended before signaling readiness" - )); - } - Err(_) => { - ssh_task.abort(); - return Err(miette::miette!( - "SSH server did not start within 10 seconds" - )); + match tokio::time::timeout(Duration::from_secs(10), ssh_ready_rx).await { + Ok(Ok(Ok(()))) => {} + Ok(Ok(Err(error))) => { + ssh_task.abort(); + return Err(error.context("SSH server failed during startup")); + } + Ok(Err(_)) => { + ssh_task.abort(); + return Err(miette::miette!( + "SSH server task ended before signaling readiness" + )); + } + Err(_) => { + ssh_task.abort(); + return Err(miette::miette!( + "SSH server did not start within 10 seconds" + )); + } } - } - let (session_task, session_readiness) = match (openshell_endpoint, sandbox_id) { - (Some(endpoint), Some(id)) => { - let (task, mut accepted) = crate::supervisor_session::spawn_with_readiness( - endpoint.to_string(), - id.to_string(), - ssh_socket_path, - port_forward, - None, - terminating.clone(), - crate::supervisor_session::SessionRuntimeContext { - instance_id: instance_id.clone(), - session_id_updates: supervisor_session_updates, - }, - ); - let accepted_result = - tokio::time::timeout(Duration::from_secs(10), accepted.wait_for(|ready| *ready)) - .await - .map(|result| result.map(|_| ())); - match accepted_result { - Ok(Ok(())) => (Some(task), Some(accepted)), - Ok(Err(_)) => { - task.abort(); - return Err(miette::miette!( - "supervisor session ended before gateway acceptance" - )); - } - Err(_) => { - task.abort(); - return Err(miette::miette!( - "gateway did not accept supervisor session within 10 seconds" - )); + let (session_task, session_readiness) = match (openshell_endpoint, sandbox_id) { + (Some(endpoint), Some(id)) => { + let (task, mut accepted) = crate::supervisor_session::spawn_with_readiness( + endpoint.to_string(), + id.to_string(), + ssh_socket_path, + port_forward, + None, + terminating.clone(), + crate::supervisor_session::SessionRuntimeContext { + instance_id: instance_id.clone(), + session_id_updates: supervisor_session_updates, + }, + ); + let accepted_result = tokio::time::timeout( + Duration::from_secs(10), + accepted.wait_for(|ready| *ready), + ) + .await + .map(|result| result.map(|_| ())); + match accepted_result { + Ok(Ok(())) => (Some(task), Some(accepted)), + Ok(Err(_)) => { + task.abort(); + return Err(miette::miette!( + "supervisor session ended before gateway acceptance" + )); + } + Err(_) => { + task.abort(); + return Err(miette::miette!( + "gateway did not accept supervisor session within 10 seconds" + )); + } } } - } - _ => (None, None), - }; + _ => (None, None), + }; - Ok(BoundaryAccess { - instance_id, - terminating, - ssh_task: Some(ssh_task), - session_task, - session_readiness, - main_session: Some(main_session), - }) + Ok(BoundaryAccess { + instance_id, + terminating, + ssh_task: Some(ssh_task), + session_task, + session_readiness, + main_session: Some(main_session), + }) + } } /// Report the canonical process exit until the gateway acknowledges it. diff --git a/crates/openshell-supervisor-process/src/lib.rs b/crates/openshell-supervisor-process/src/lib.rs index 023a6c8e73..df0f21a519 100644 --- a/crates/openshell-supervisor-process/src/lib.rs +++ b/crates/openshell-supervisor-process/src/lib.rs @@ -12,7 +12,9 @@ pub mod delegated; pub mod log_push; pub mod main_session; pub mod skills; +#[cfg(unix)] pub mod ssh; pub mod supervisor_session; +#[cfg(unix)] mod unix_socket; diff --git a/crates/openshell-supervisor-process/src/main_session.rs b/crates/openshell-supervisor-process/src/main_session.rs index 3ffa24d1d0..7aee0265f1 100644 --- a/crates/openshell-supervisor-process/src/main_session.rs +++ b/crates/openshell-supervisor-process/src/main_session.rs @@ -4,14 +4,19 @@ //! Retained I/O multiplexer for the canonical sandbox process. use std::collections::VecDeque; +#[cfg(unix)] use std::io::{Read, Write}; +#[cfg(unix)] use std::os::fd::AsRawFd; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use bytes::Bytes; +#[cfg(unix)] use nix::fcntl::{FcntlArg, OFlag, fcntl}; +#[cfg(unix)] use nix::pty::Winsize; +#[cfg(unix)] use tokio::io::unix::AsyncFd; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::sync::Notify; @@ -24,6 +29,7 @@ use openshell_isolation_interface::contract::{ const OUTPUT_BUFFER_BYTES: usize = 1024 * 1024; /// Canonical-process I/O retained by the supervisor session multiplexer. +#[cfg(unix)] pub enum ProcessIo { Pty(std::fs::File), Pipes { @@ -191,12 +197,14 @@ impl MainOutputCursor { } pub struct MainSession { + #[cfg(unix)] pid: u32, terminal: bool, input: tokio::sync::mpsc::Sender>, output: Arc, input_owner: Mutex>, next_owner: AtomicU64, + #[cfg(unix)] pty_master: Option>, boundary_process: Option>, boundary_terminal: Option>, @@ -213,12 +221,14 @@ impl MainSession { pub fn inert() -> Arc { let (input, _input_rx) = tokio::sync::mpsc::channel(64); Arc::new(Self { + #[cfg(unix)] pid: 1, terminal: false, input, output: OutputLog::new(), input_owner: Mutex::new(None), next_owner: AtomicU64::new(1), + #[cfg(unix)] pty_master: None, boundary_process: None, boundary_terminal: None, @@ -234,7 +244,7 @@ impl MainSession { }) } - #[cfg(test)] + #[cfg(all(test, unix))] pub fn terminal_for_test() -> (Arc, std::fs::File) { let pty = nix::pty::openpty(None, None).expect("open test PTY"); let slave = std::fs::File::from(pty.slave); @@ -244,7 +254,7 @@ impl MainSession { ) } - #[cfg(test)] + #[cfg(all(test, unix))] #[allow(unsafe_code)] pub fn terminal_size_for_test(&self) -> (u16, u16) { let master = self.pty_master.as_ref().expect("terminal PTY master"); @@ -255,6 +265,7 @@ impl MainSession { } #[must_use] + #[cfg(unix)] pub fn new(io: ProcessIo, pid: u32) -> Arc { let terminal = matches!(io, ProcessIo::Pty(_)); let (input, input_rx) = tokio::sync::mpsc::channel::>(64); @@ -272,6 +283,7 @@ impl MainSession { output: OutputLog::new(), input_owner: Mutex::new(None), next_owner: AtomicU64::new(1), + #[cfg(unix)] pty_master, boundary_process: None, boundary_terminal: None, @@ -306,12 +318,14 @@ impl MainSession { let terminal_mode = terminal.is_some(); let (input, mut input_rx) = tokio::sync::mpsc::channel::>(64); let session = Arc::new(Self { + #[cfg(unix)] pid: 0, terminal: terminal_mode, input, output: OutputLog::new(), input_owner: Mutex::new(None), next_owner: AtomicU64::new(1), + #[cfg(unix)] pty_master: None, boundary_process: Some(process), boundary_terminal: terminal, @@ -364,6 +378,7 @@ impl MainSession { session } + #[cfg(unix)] fn start_io( this: &Arc, io: ProcessIo, @@ -649,38 +664,47 @@ impl MainSession { .await; return; } - let Some(master) = self.pty_master.as_ref() else { - return; - }; - let winsize = Winsize { - ws_row: u16::try_from(rows.max(1)).unwrap_or(u16::MAX), - ws_col: u16::try_from(columns.max(1)).unwrap_or(u16::MAX), - ws_xpixel: u16::try_from(pixel_width).unwrap_or(u16::MAX), - ws_ypixel: u16::try_from(pixel_height).unwrap_or(u16::MAX), - }; - #[allow(unsafe_code)] - unsafe { - libc::ioctl(master.as_raw_fd(), libc::TIOCSWINSZ, &winsize); + #[cfg(not(unix))] + let _ = (columns, rows, pixel_width, pixel_height); + #[cfg(unix)] + { + let Some(master) = self.pty_master.as_ref() else { + return; + }; + let winsize = Winsize { + ws_row: u16::try_from(rows.max(1)).unwrap_or(u16::MAX), + ws_col: u16::try_from(columns.max(1)).unwrap_or(u16::MAX), + ws_xpixel: u16::try_from(pixel_width).unwrap_or(u16::MAX), + ws_ypixel: u16::try_from(pixel_height).unwrap_or(u16::MAX), + }; + #[allow(unsafe_code)] + unsafe { + libc::ioctl(master.as_raw_fd(), libc::TIOCSWINSZ, &winsize); + } } } - pub async fn signal_group(&self, signal: nix::sys::signal::Signal) -> Result<(), String> { + pub async fn signal_group(&self, signal: BoundarySignal) -> Result<(), String> { if let Some(process) = self.boundary_process.as_ref() { - let signal = match signal { - nix::sys::signal::Signal::SIGHUP => BoundarySignal::Hup, - nix::sys::signal::Signal::SIGINT => BoundarySignal::Int, - nix::sys::signal::Signal::SIGKILL => BoundarySignal::Kill, - nix::sys::signal::Signal::SIGTERM => BoundarySignal::Term, - other => return Err(format!("boundary signal {other:?} is unsupported")), - }; return process .signal(signal) .await .map_err(|error| error.to_string()); } - let pid = i32::try_from(self.pid).unwrap_or(i32::MAX); - nix::sys::signal::kill(nix::unistd::Pid::from_raw(-pid), signal) - .map_err(|error| error.to_string()) + #[cfg(unix)] + { + let pid = i32::try_from(self.pid).unwrap_or(i32::MAX); + let signal = match signal { + BoundarySignal::Hup => nix::sys::signal::Signal::SIGHUP, + BoundarySignal::Int => nix::sys::signal::Signal::SIGINT, + BoundarySignal::Kill => nix::sys::signal::Signal::SIGKILL, + BoundarySignal::Term => nix::sys::signal::Signal::SIGTERM, + }; + nix::sys::signal::kill(nix::unistd::Pid::from_raw(-pid), signal) + .map_err(|error| error.to_string()) + } + #[cfg(not(unix))] + Err("local process-group signaling is unsupported on Windows".to_string()) } #[must_use] @@ -694,6 +718,7 @@ impl MainSession { } } +#[cfg(unix)] fn set_nonblocking(file: &std::fs::File) -> Result<(), nix::errno::Errno> { let flags = fcntl(file.as_raw_fd(), FcntlArg::F_GETFL)?; let flags = OFlag::from_bits_truncate(flags); @@ -778,10 +803,7 @@ mod tests { session.resize(120, 40, 0, 0).await; assert_eq!(*terminal.size.lock().unwrap(), Some((120, 40))); - session - .signal_group(nix::sys::signal::Signal::SIGINT) - .await - .unwrap(); + session.signal_group(BoundarySignal::Int).await.unwrap(); assert_eq!(*process.signals.lock().unwrap(), vec![BoundarySignal::Int]); } diff --git a/crates/openshell-supervisor-process/src/ssh.rs b/crates/openshell-supervisor-process/src/ssh.rs index c93f361e24..a4f39aab6e 100644 --- a/crates/openshell-supervisor-process/src/ssh.rs +++ b/crates/openshell-supervisor-process/src/ssh.rs @@ -841,11 +841,10 @@ impl russh::server::Handler for SshHandler { .is_some_and(|state| state.main_attached) { let signal = match signal { - Sig::HUP => Some(nix::sys::signal::Signal::SIGHUP), - Sig::INT => Some(nix::sys::signal::Signal::SIGINT), - Sig::KILL => Some(nix::sys::signal::Signal::SIGKILL), - Sig::QUIT => Some(nix::sys::signal::Signal::SIGQUIT), - Sig::TERM => Some(nix::sys::signal::Signal::SIGTERM), + Sig::HUP => Some(openshell_isolation_interface::contract::BoundarySignal::Hup), + Sig::INT => Some(openshell_isolation_interface::contract::BoundarySignal::Int), + Sig::KILL => Some(openshell_isolation_interface::contract::BoundarySignal::Kill), + Sig::TERM => Some(openshell_isolation_interface::contract::BoundarySignal::Term), _ => None, }; if let (Some(signal), Some(main_session)) = (signal, self.main_session.as_ref()) diff --git a/crates/openshell-supervisor-process/src/supervisor_session.rs b/crates/openshell-supervisor-process/src/supervisor_session.rs index 32cbc2d1ee..6ea25ae890 100644 --- a/crates/openshell-supervisor-process/src/supervisor_session.rs +++ b/crates/openshell-supervisor-process/src/supervisor_session.rs @@ -769,22 +769,29 @@ async fn open_target( port_forward: &Arc, expected_ssh_peer_pid: Option, ) -> Result, Box> { + #[cfg(not(unix))] + let _ = (ssh_socket_path, expected_ssh_peer_pid); match relay_open.target.as_ref() { Some(relay_open::Target::Tcp(target)) => open_tcp_target(target, port_forward).await, Some(relay_open::Target::Ssh(_)) | None => { - let runtime_path = crate::unix_socket::runtime_path(ssh_socket_path); - let stream = tokio::net::UnixStream::connect(runtime_path.as_ref()).await?; - if let Some(expected_pid) = expected_ssh_peer_pid { - let credentials = stream.peer_cred()?; - let actual_pid = credentials.pid().and_then(|pid| u32::try_from(pid).ok()); - if actual_pid != Some(expected_pid) { - return Err(format!( + #[cfg(not(unix))] + return Err("SSH relay targets are unsupported by the Windows supervisor".into()); + #[cfg(unix)] + { + let runtime_path = crate::unix_socket::runtime_path(ssh_socket_path); + let stream = tokio::net::UnixStream::connect(runtime_path.as_ref()).await?; + if let Some(expected_pid) = expected_ssh_peer_pid { + let credentials = stream.peer_cred()?; + let actual_pid = credentials.pid().and_then(|pid| u32::try_from(pid).ok()); + if actual_pid != Some(expected_pid) { + return Err(format!( "SSH relay peer PID mismatch: expected {expected_pid}, got {actual_pid:?}" ) .into()); + } } + Ok(Box::new(stream)) } - Ok(Box::new(stream)) } } } diff --git a/crates/openshell-supervisor/src/lib.rs b/crates/openshell-supervisor/src/lib.rs index e05da07067..14b9230fdb 100644 --- a/crates/openshell-supervisor/src/lib.rs +++ b/crates/openshell-supervisor/src/lib.rs @@ -89,11 +89,13 @@ where } } +#[cfg(unix)] struct ControlReadiness { task: tokio::task::JoinHandle<()>, path: std::path::PathBuf, } +#[cfg(unix)] impl ControlReadiness { fn start( path: std::path::PathBuf, @@ -221,6 +223,7 @@ fn prepare_control_readiness_path(path: &std::path::Path) -> Result<()> { Ok(()) } +#[cfg(unix)] impl Drop for ControlReadiness { fn drop(&mut self) { self.task.abort(); @@ -228,6 +231,21 @@ impl Drop for ControlReadiness { } } +#[cfg(not(unix))] +struct ControlReadiness; + +#[cfg(not(unix))] +impl ControlReadiness { + fn start( + _path: std::path::PathBuf, + _session_readiness: Option>, + ) -> Result { + Err(miette::miette!( + "supervisor readiness sockets require a Unix host" + )) + } +} + /// Check whether the live supervisor owns its private readiness socket. #[cfg(unix)] pub fn check_control_readiness(path: &std::path::Path) -> Result<()> { @@ -509,6 +527,7 @@ pub async fn run_network_proxy( #[cfg(target_os = "linux")] None, None, + None, ) .await?; @@ -869,7 +888,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; @@ -907,7 +929,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?, ); @@ -4360,6 +4383,7 @@ mod tests { assert!(prepare_network_proxy_tls_dir(Some(writable)).is_err()); } + #[cfg(unix)] #[tokio::test] async fn control_readiness_exists_only_while_guard_is_live() { let root = tempfile::tempdir().unwrap(); @@ -4373,6 +4397,7 @@ mod tests { assert!(check_control_readiness(&path).is_err()); } + #[cfg(unix)] #[tokio::test] async fn control_readiness_tracks_supervisor_session() { let root = tempfile::tempdir().unwrap(); @@ -4401,6 +4426,7 @@ mod tests { .expect("replacement session restores readiness socket"); } + #[cfg(unix)] #[test] fn control_readiness_rejects_relative_path() { let error = prepare_control_readiness_path(std::path::Path::new("health.sock")) diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 23705a24e9..984e427b1d 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -823,6 +823,7 @@ async fn handle_sandbox_delete(app: &mut App, tx: mpsc::UnboundedSender) allow_missing: true, name: sandbox_name, workspace_scope: Some(named_workspace_scope(app.selected_sandbox_workspace())), + ..Default::default() }; match app.client.delete_sandbox(req).await { Ok(response) => { diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 9940f2ee2e..e1fcf0a96b 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -645,7 +645,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. @@ -660,13 +660,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-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 @@ -888,6 +889,50 @@ 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-sandbox` inside each ProcessContainer. The standard authenticated Sandbox Protocol and supervisor session provide lifecycle, exec, forwarding, provider refresh, and network policy. + +```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-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. + +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/reference/policy-schema.mdx b/docs/reference/policy-schema.mdx index 3e4c051d1c..d4f86c530e 100644 --- a/docs/reference/policy-schema.mdx +++ b/docs/reference/policy-schema.mdx @@ -19,6 +19,7 @@ version: 1 filesystem_policy: { ... } landlock: { ... } process: { ... } +ui: { ... } network_policies: { ... } network_middlewares: { ... } ``` @@ -29,6 +30,7 @@ network_middlewares: { ... } | `filesystem_policy` | object | No | Static | Controls which directories the agent can read and write. | | `landlock` | object | No | Static | Configures Landlock LSM enforcement behavior. | | `process` | object | No | Static | Sets the user and group the agent process runs as. | +| `ui` | object | No | Static | Controls graphical UI, directional clipboard access, and synthetic input on compute drivers that advertise complete enforcement. | | `network_policies` | map | No | Dynamic | Declares which binaries can reach which network endpoints. | | `network_middlewares` | map | No | Dynamic | Attaches ordered middleware by destination host; each implementation's manifest selects its supported HTTP and WebSocket operations. | @@ -186,6 +188,44 @@ process: run_as_group: "1500" ``` +## UI + +**Category:** Static + +Declares platform-neutral UI capabilities. Within an explicit section, every +field is deny by default, so `ui: {}` grants nothing on a supporting backend. +Omit the entire section when the workload does not need a UI surface. + +| Field | Type | Required | Values | Description | +|---|---|---|---|---| +| `allow_graphical_ui` | bool | No | `true`, `false` | Allows the workload to display graphical windows. Defaults to `false`. | +| `clipboard` | string | No | `none`, `read`, `write`, `all` | Controls host clipboard direction from the sandbox's perspective. Defaults to `none`. | +| `allow_input_injection` | bool | No | `true`, `false` | Allows synthetic keyboard or pointer input. Defaults to `false`. | + +MXC `process_container` enforces this section on Windows and advertises complete +support through the compute-driver capability contract. MXC's `disable` switch +suppresses the other UI fields, so a clipboard or input-injection grant on this +backend also requires `allow_graphical_ui: true`; OpenShell rejects a grant that +MXC would ignore. OpenShell rejects any explicit UI section, including `{}`, with +MXC `isolation_session` because current MXC rejects the top-level `ui` object +there. Docker, Podman, Kubernetes, VM, and older or partial extension drivers +also advertise no support, so the gateway rejects explicit UI policy before +validation or provisioning. Omitting the section preserves each runtime's +existing behavior. + +UI is a per-sandbox startup contract and cannot be set in a gateway-global +policy. A global policy changes dynamic policy fields while each sandbox keeps +the UI controls applied from its own policy at creation. + +Example: + +```yaml showLineNumbers={false} +ui: + allow_graphical_ui: true + clipboard: read + allow_input_injection: false +``` + ## Network Policies **Category:** Dynamic diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index 86968a9910..eaf076068f 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -256,7 +256,7 @@ Create a sandbox from a template: openshell sandbox create --template gpu-kata --provider github -- claude ``` -The `--template` flag cannot be combined with inline workload flags such as `--from`, `--cpu`, `--memory`, `--gpu`, `--env`, or `--driver-config-json`. Put those values on the template instead. Create-time policy and provider attachments remain part of the sandbox request, so each sandbox can keep its own access boundary. +The `--template` flag cannot be combined with inline workload flags such as `--from`, `--cpu`, `--memory`, `--gpu`, `--env`, `--env-from`, or `--driver-config-json`. Put those values on the template instead. Create-time policy and provider attachments remain part of the sandbox request, so each sandbox can keep its own access boundary. Inspect and manage templates: @@ -373,6 +373,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](/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`: @@ -756,6 +766,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/sandboxes/policies.mdx b/docs/sandboxes/policies.mdx index f4202319ae..e0d9dda7a6 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -12,7 +12,7 @@ Use this page to apply and iterate policy changes on running sandboxes. For a fu ## Policy Structure -A policy has static sections `filesystem_policy`, `landlock`, and `process` that are locked at sandbox creation, and dynamic `network_policies` and `network_middlewares` sections that are hot-reloadable on a running sandbox. +A policy has static sections `filesystem_policy`, `landlock`, `process`, and `ui` that are locked at sandbox creation. `network_policies` and `network_middlewares` are dynamic schema sections, but live updates work only on compute drivers that support runtime policy reload. MXC rejects live policy replacement and merge updates, so recreate the sandbox there. ```yaml wordWrap showLineNumbers={false} version: 1 @@ -32,6 +32,13 @@ landlock: # run_as_user: "1500" # run_as_group: "1500" +# Static, optional: portable UI capabilities. Within this explicit section, +# omitted values deny access. Only drivers advertising complete support accept it. +# ui: +# allow_graphical_ui: true +# clipboard: read +# allow_input_injection: false + # Dynamic: hot-reloadable. Named blocks of endpoints + binaries allowed to reach them. network_policies: my_api: @@ -69,6 +76,7 @@ When a hot reload changes rules, the supervisor publishes a new policy generatio | `filesystem_policy` | Static | Controls which directories the agent can access on disk. Paths are split into `read_only` and `read_write` lists. Any path not listed in either list is inaccessible. Set `include_workdir: true` to automatically add the agent's working directory to `read_write`. [Landlock LSM](https://docs.kernel.org/security/landlock.html) enforces these restrictions at the kernel level. | | `landlock` | Static | Configures Landlock LSM enforcement behavior. Set `compatibility` to `best_effort` (skip individual inaccessible paths while applying remaining rules) or `hard_requirement` (fail if any path is inaccessible or the required kernel ABI is unavailable). Refer to the [Policy Schema Reference](/reference/policy-schema#landlock) for the full behavior table. | | `process` | Static | Optionally overrides the OS-level identity for the agent process. Explicit values must be `sandbox` or numeric UID/GID values from `1` through `4294967294`; root and the invalid identity sentinel are rejected. Docker and Podman may use named identities through per-field OCI `USER` fallback; Kubernetes uses its platform-selected numeric identity. The agent also runs with seccomp filters that block dangerous system calls. | +| `ui` | Static | Controls portable graphical UI, directional clipboard, and synthetic-input capabilities. Within an explicit section, every omitted value denies. MXC `process_container` advertises complete enforcement. MXC `isolation_session`, Docker, Podman, Kubernetes, VM, and partial extension drivers reject any explicit section before provisioning. Omission preserves existing runtime behavior. | | `network_policies` | Dynamic | Controls outbound traffic from the sandbox, including native model-provider endpoints. Each block has a name, a list of endpoints (host, port, protocol, and optional rules), and a list of binaries allowed to use those endpoints.
Every outbound connection passes through the network supervisor, which queries the [policy engine](/about/how-it-works#core-components) with the destination and calling binary. A connection is allowed only when both match an entry in the same policy block. Attached provider profiles can contribute endpoint and binary entries to the effective policy.
For endpoints with `protocol: rest`, the proxy auto-detects TLS and terminates it so each HTTP request can be checked against that endpoint's `rules` (method and path). For endpoints with `protocol: websocket`, the proxy validates the RFC 6455 upgrade and evaluates `GET` rules for the handshake plus either `WEBSOCKET_TEXT` rules for raw client text messages or GraphQL operation rules for GraphQL-over-WebSocket messages. Set `websocket_credential_rewrite: true` only when a WebSocket or REST compatibility endpoint must keep placeholder credentials in sandbox-owned text frames and resolve them at the OpenShell relay boundary.
Endpoints with `protocol: tcp` allow ordinary DNS resolution and native TCP connections without inspecting payloads. Endpoints without `protocol` retain L4 passthrough through an explicit proxy.
If no endpoint matches, the connection is denied. | | `network_middlewares` | Dynamic | Declares keyed HTTP and WebSocket middleware configs. After network and L7 policy admit a request or upgrade, OpenShell matches each config's host selectors independently and runs matching entries by their unique ascending `order` before credential injection. WebSocket-capable entries continue on complete client text messages. | @@ -607,7 +615,12 @@ openshell policy set --global --policy ./global-policy.yaml When a global policy is configured: -- The global payload is applied in full for all sandboxes. +- The global payload replaces every sandbox's complete effective policy, + including static sections such as `filesystem_policy`, `landlock`, and + `process`, not only the dynamic `network_policies` and + `network_middlewares` fields. +- Each sandbox keeps the startup-only `ui` section from its own creation policy; + a global policy containing `ui` is rejected. - Sandbox-level policy updates are rejected until the global policy is removed. To restore sandbox-level policy control, delete the global policy setting: diff --git a/e2e/configs/gateway/schema-v2-capability-parity.toml b/e2e/configs/gateway/schema-v2-capability-parity.toml index 3927f81df1..305974eca6 100644 --- a/e2e/configs/gateway/schema-v2-capability-parity.toml +++ b/e2e/configs/gateway/schema-v2-capability-parity.toml @@ -332,7 +332,7 @@ status = "not_run" [[capabilities]] id = "mxc-windows-driver-configuration" topics = ["mxc"] -origin_main_access_paths = ["[openshell.drivers.mxc].{wxc_exec_path,backend,pc_least_privilege,pc_capabilities,default_configuration_id,debug}"] +origin_main_access_paths = ["[openshell.drivers.mxc].{wxc_exec_path,supervisor_binary_path,sandbox_binary_path,state_dir,grpc_endpoint,backend,pc_least_privilege,pc_capabilities,pc_allow_local_network,pc_minimal_env,debug,etw_audit}"] schema_v2_access_paths = ["same [openshell.drivers.mxc] fields"] behavioral_oracle = "The Windows gateway selects MXC and passes the configured wxc-exec path, backend, AppContainer capabilities, isolation configuration, and debug flag to MXC." required_environment = "Windows host with MXC/wxc-exec; mock fixture for deterministic smoke variant" diff --git a/proto/compute_driver.proto b/proto/compute_driver.proto index 7147c7a6f6..17fb91412d 100644 --- a/proto/compute_driver.proto +++ b/proto/compute_driver.proto @@ -101,6 +101,10 @@ message GetCapabilitiesResponse { // Maximum rootfs tar file size in bytes accepted by the driver. Zero means // the driver does not support rootfs tar sources. uint64 rootfs_tar_max_bytes = 11; + // 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 = 12; } message AuthenticateSandboxRequest { diff --git a/proto/openshell.proto b/proto/openshell.proto index 0e9fe429a6..8981c6c1d3 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -875,6 +875,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. @@ -1347,9 +1351,19 @@ message DeleteSandboxRequest { // Succeed with ALREADY_ABSENT if the target is missing. Does not wait for // asynchronous cleanup and does not suppress authorization or parent errors. bool allow_missing = 4; + // Optional immutable sandbox identity precondition. When non-empty, the + // gateway rejects the request with ABORTED unless the currently resolved + // sandbox has this exact metadata ID. The check is repeated under the + // lifecycle lock immediately before any delete mutation. + string expected_sandbox_id = 5; + // Optional optimistic-concurrency precondition. Requires + // expected_sandbox_id. When non-zero, the gateway rejects the request with + // ABORTED unless the sandbox's current resource version matches this value + // immediately before any delete mutation. + uint64 expected_resource_version = 6; // Optional nonzero UUID for durable at-most-once admission. Successful results // can be replayed for 24 hours; see the API errors and retries reference. - string request_id = 5; + string request_id = 7; } // Stop sandbox request. diff --git a/proto/sandbox.proto b/proto/sandbox.proto index 600f4a750b..6e3cc4dab0 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -32,6 +32,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. @@ -58,6 +62,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 515e7387dd..a9229e9468 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-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-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-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-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-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-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, `inference.local`, 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 0fa43a59d5..fdf8a0861f 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 c9dcc8829b..14ab91b3c7 100644 --- a/sdk/go/openshell/v1/fake/sandbox_test.go +++ b/sdk/go/openshell/v1/fake/sandbox_test.go @@ -837,6 +837,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", @@ -874,6 +879,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"] @@ -886,19 +895,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 77d8f72ea6..a59f20ffd4 100644 --- a/sdk/go/openshell/v1/internal/converter/coverage_test.go +++ b/sdk/go/openshell/v1/internal/converter/coverage_test.go @@ -162,6 +162,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 63ab8c3298..f2f873ae8e 100644 --- a/sdk/go/openshell/v1/internal/converter/health.go +++ b/sdk/go/openshell/v1/internal/converter/health.go @@ -48,6 +48,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 d0360a9b66..0a9cfb6a3f 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, }, }, { @@ -43,8 +44,10 @@ 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) + assert.False(t, info.ComputeDrivers[1].SupportsUIPolicy) } func TestGatewayInfoFromProto_NoDrivers(t *testing.T) { @@ -107,6 +110,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 1db3ec3872..14a4e2544a 100644 --- a/sdk/go/openshell/v1/types/health.go +++ b/sdk/go/openshell/v1/types/health.go @@ -29,9 +29,10 @@ type GatewayInfo 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 310df01110..e7df8afbbb 100644 --- a/sdk/go/proto/openshellv1/openshell.pb.go +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -1269,8 +1269,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() { @@ -1324,6 +1327,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 { @@ -3673,9 +3683,19 @@ type DeleteSandboxRequest struct { // Succeed with ALREADY_ABSENT if the target is missing. Does not wait for // asynchronous cleanup and does not suppress authorization or parent errors. AllowMissing bool `protobuf:"varint,4,opt,name=allow_missing,json=allowMissing,proto3" json:"allow_missing,omitempty"` + // Optional immutable sandbox identity precondition. When non-empty, the + // gateway rejects the request with ABORTED unless the currently resolved + // sandbox has this exact metadata ID. The check is repeated under the + // lifecycle lock immediately before any delete mutation. + ExpectedSandboxId string `protobuf:"bytes,5,opt,name=expected_sandbox_id,json=expectedSandboxId,proto3" json:"expected_sandbox_id,omitempty"` + // Optional optimistic-concurrency precondition. Requires + // expected_sandbox_id. When non-zero, the gateway rejects the request with + // ABORTED unless the sandbox's current resource version matches this value + // immediately before any delete mutation. + ExpectedResourceVersion uint64 `protobuf:"varint,6,opt,name=expected_resource_version,json=expectedResourceVersion,proto3" json:"expected_resource_version,omitempty"` // Optional nonzero UUID for durable at-most-once admission. Successful results // can be replayed for 24 hours; see the API errors and retries reference. - RequestId string `protobuf:"bytes,5,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + RequestId string `protobuf:"bytes,7,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -3731,6 +3751,20 @@ func (x *DeleteSandboxRequest) GetAllowMissing() bool { return false } +func (x *DeleteSandboxRequest) GetExpectedSandboxId() string { + if x != nil { + return x.ExpectedSandboxId + } + return "" +} + +func (x *DeleteSandboxRequest) GetExpectedResourceVersion() uint64 { + if x != nil { + return x.ExpectedResourceVersion + } + return 0 +} + func (x *DeleteSandboxRequest) GetRequestId() string { if x != nil { return x.RequestId @@ -15211,12 +15245,13 @@ const file_openshell_proto_rawDesc = "" + "\x0fcompute_drivers\x18\x03 \x03(\v2\x1f.openshell.v1.ComputeDriverInfoR\x0ecomputeDrivers\"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" + @@ -15413,13 +15448,15 @@ const file_openshell_proto_rawDesc = "" + "\x19expected_resource_version\x18\x03 \x01(\x04R\x17expectedResourceVersion\x12R\n" + "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1d\n" + "\n" + - "request_id\x18\x06 \x01(\tR\trequestIdJ\x04\b\x04\x10\x05R\tworkspace\"\xd3\x01\n" + + "request_id\x18\x06 \x01(\tR\trequestIdJ\x04\b\x04\x10\x05R\tworkspace\"\xbf\x02\n" + "\x14DeleteSandboxRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12R\n" + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12#\n" + - "\rallow_missing\x18\x04 \x01(\bR\fallowMissing\x12\x1d\n" + + "\rallow_missing\x18\x04 \x01(\bR\fallowMissing\x12.\n" + + "\x13expected_sandbox_id\x18\x05 \x01(\tR\x11expectedSandboxId\x12:\n" + + "\x19expected_resource_version\x18\x06 \x01(\x04R\x17expectedResourceVersion\x12\x1d\n" + "\n" + - "request_id\x18\x05 \x01(\tR\trequestIdJ\x04\b\x02\x10\x03R\tworkspace\"\xac\x01\n" + + "request_id\x18\a \x01(\tR\trequestIdJ\x04\b\x02\x10\x03R\tworkspace\"\xac\x01\n" + "\x12StopSandboxRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12R\n" + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1d\n" + diff --git a/sdk/go/proto/sandboxv1/sandbox.pb.go b/sdk/go/proto/sandboxv1/sandbox.pb.go index 6cbbd5fbda..1c3b1db33d 100644 --- a/sdk/go/proto/sandboxv1/sandbox.pb.go +++ b/sdk/go/proto/sandboxv1/sandbox.pb.go @@ -26,6 +26,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} +} + // Scope that currently controls a setting. type SettingScope int32 @@ -60,11 +121,11 @@ func (x SettingScope) String() string { } func (SettingScope) Descriptor() protoreflect.EnumDescriptor { - return file_sandbox_proto_enumTypes[0].Descriptor() + return file_sandbox_proto_enumTypes[1].Descriptor() } func (SettingScope) Type() protoreflect.EnumType { - return &file_sandbox_proto_enumTypes[0] + return &file_sandbox_proto_enumTypes[1] } func (x SettingScope) Number() protoreflect.EnumNumber { @@ -73,7 +134,7 @@ func (x SettingScope) Number() protoreflect.EnumNumber { // Deprecated: Use SettingScope.Descriptor instead. func (SettingScope) EnumDescriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{0} + return file_sandbox_proto_rawDescGZIP(), []int{1} } // Source used for the policy payload in GetSandboxConfigResponse. @@ -110,11 +171,11 @@ func (x PolicySource) String() string { } func (PolicySource) Descriptor() protoreflect.EnumDescriptor { - return file_sandbox_proto_enumTypes[1].Descriptor() + return file_sandbox_proto_enumTypes[2].Descriptor() } func (PolicySource) Type() protoreflect.EnumType { - return &file_sandbox_proto_enumTypes[1] + return &file_sandbox_proto_enumTypes[2] } func (x PolicySource) Number() protoreflect.EnumNumber { @@ -123,7 +184,7 @@ func (x PolicySource) Number() protoreflect.EnumNumber { // Deprecated: Use PolicySource.Descriptor instead. func (PolicySource) EnumDescriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{1} + return file_sandbox_proto_rawDescGZIP(), []int{2} } // Sandbox security policy configuration. @@ -143,8 +204,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() { @@ -219,6 +284,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"` @@ -384,6 +456,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"` @@ -399,7 +537,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) } @@ -411,7 +549,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 { @@ -424,7 +562,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 { @@ -469,7 +607,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) } @@ -481,7 +619,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 { @@ -494,7 +632,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 { @@ -554,7 +692,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) } @@ -566,7 +704,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 { @@ -579,7 +717,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 { @@ -608,7 +746,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) } @@ -620,7 +758,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 { @@ -633,7 +771,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 { @@ -745,7 +883,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) } @@ -757,7 +895,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 { @@ -770,7 +908,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 { @@ -996,7 +1134,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) } @@ -1008,7 +1146,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 { @@ -1021,7 +1159,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 { @@ -1060,7 +1198,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) } @@ -1072,7 +1210,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 { @@ -1085,7 +1223,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 { @@ -1140,7 +1278,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) } @@ -1152,7 +1290,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 { @@ -1165,7 +1303,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 { @@ -1234,7 +1372,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) } @@ -1246,7 +1384,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 { @@ -1259,7 +1397,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 { @@ -1299,7 +1437,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) } @@ -1311,7 +1449,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 { @@ -1324,7 +1462,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 { @@ -1396,7 +1534,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) } @@ -1408,7 +1546,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 { @@ -1421,7 +1559,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 { @@ -1448,7 +1586,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) } @@ -1460,7 +1598,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 { @@ -1473,7 +1611,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 { @@ -1494,7 +1632,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) } @@ -1506,7 +1644,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 { @@ -1519,7 +1657,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) GetSandboxId() string { @@ -1538,7 +1676,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) } @@ -1550,7 +1688,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 { @@ -1563,7 +1701,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. @@ -1580,7 +1718,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) } @@ -1592,7 +1730,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 { @@ -1605,7 +1743,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 { @@ -1638,7 +1776,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) } @@ -1650,7 +1788,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 { @@ -1663,7 +1801,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 { @@ -1748,7 +1886,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) } @@ -1760,7 +1898,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 { @@ -1773,7 +1911,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 { @@ -1832,7 +1970,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) } @@ -1844,7 +1982,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 { @@ -1857,7 +1995,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 { @@ -1976,7 +2114,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) } @@ -1988,7 +2126,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 { @@ -2001,7 +2139,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 { @@ -2057,7 +2195,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\"\xa8\x05\n" + + "\rsandbox.proto\x12\x14openshell.sandbox.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1egoogle/protobuf/duration.proto\"\xd8\x05\n" + "\rSandboxPolicy\x12\x18\n" + "\aversion\x18\x01 \x01(\rR\aversion\x12F\n" + "\n" + @@ -2066,7 +2204,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" + @@ -2083,7 +2222,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" + @@ -2233,7 +2376,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*b\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*b\n" + "\fSettingScope\x12\x1d\n" + "\x19SETTING_SCOPE_UNSPECIFIED\x10\x00\x12\x19\n" + "\x15SETTING_SCOPE_SANDBOX\x10\x01\x12\x18\n" + @@ -2255,88 +2404,92 @@ func file_sandbox_proto_rawDescGZIP() []byte { return file_sandbox_proto_rawDescData } -var file_sandbox_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_sandbox_proto_msgTypes = make([]protoimpl.MessageInfo, 32) +var file_sandbox_proto_enumTypes = make([]protoimpl.EnumInfo, 3) +var file_sandbox_proto_msgTypes = make([]protoimpl.MessageInfo, 33) var file_sandbox_proto_goTypes = []any{ - (SettingScope)(0), // 0: openshell.sandbox.v1.SettingScope - (PolicySource)(0), // 1: openshell.sandbox.v1.PolicySource - (*SandboxPolicy)(nil), // 2: openshell.sandbox.v1.SandboxPolicy - (*FilesystemPolicy)(nil), // 3: openshell.sandbox.v1.FilesystemPolicy - (*LandlockPolicy)(nil), // 4: openshell.sandbox.v1.LandlockPolicy - (*ProcessPolicy)(nil), // 5: openshell.sandbox.v1.ProcessPolicy - (*NetworkPolicyRule)(nil), // 6: openshell.sandbox.v1.NetworkPolicyRule - (*NetworkMiddlewareConfig)(nil), // 7: openshell.sandbox.v1.NetworkMiddlewareConfig - (*MiddlewareEndpointSelector)(nil), // 8: openshell.sandbox.v1.MiddlewareEndpointSelector - (*NetworkCredentialBinding)(nil), // 9: openshell.sandbox.v1.NetworkCredentialBinding - (*NetworkEndpoint)(nil), // 10: openshell.sandbox.v1.NetworkEndpoint - (*McpOptions)(nil), // 11: openshell.sandbox.v1.McpOptions - (*GraphqlOperation)(nil), // 12: openshell.sandbox.v1.GraphqlOperation - (*L7DenyRule)(nil), // 13: openshell.sandbox.v1.L7DenyRule - (*L7Rule)(nil), // 14: openshell.sandbox.v1.L7Rule - (*L7Allow)(nil), // 15: openshell.sandbox.v1.L7Allow - (*L7QueryMatcher)(nil), // 16: openshell.sandbox.v1.L7QueryMatcher - (*NetworkBinary)(nil), // 17: openshell.sandbox.v1.NetworkBinary - (*GetSandboxConfigRequest)(nil), // 18: openshell.sandbox.v1.GetSandboxConfigRequest - (*GetGatewayConfigRequest)(nil), // 19: openshell.sandbox.v1.GetGatewayConfigRequest - (*GetGatewayConfigResponse)(nil), // 20: openshell.sandbox.v1.GetGatewayConfigResponse - (*SettingValue)(nil), // 21: openshell.sandbox.v1.SettingValue - (*EffectiveSetting)(nil), // 22: openshell.sandbox.v1.EffectiveSetting - (*GetSandboxConfigResponse)(nil), // 23: openshell.sandbox.v1.GetSandboxConfigResponse - (*SupervisorMiddlewareService)(nil), // 24: openshell.sandbox.v1.SupervisorMiddlewareService - nil, // 25: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry - nil, // 26: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry - nil, // 27: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry - nil, // 28: openshell.sandbox.v1.L7DenyRule.QueryEntry - nil, // 29: openshell.sandbox.v1.L7DenyRule.ParamsEntry - nil, // 30: openshell.sandbox.v1.L7Allow.QueryEntry - nil, // 31: openshell.sandbox.v1.L7Allow.ParamsEntry - nil, // 32: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry - nil, // 33: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry - (*structpb.Struct)(nil), // 34: google.protobuf.Struct - (*durationpb.Duration)(nil), // 35: google.protobuf.Duration + (UiClipboardAccess)(0), // 0: openshell.sandbox.v1.UiClipboardAccess + (SettingScope)(0), // 1: openshell.sandbox.v1.SettingScope + (PolicySource)(0), // 2: openshell.sandbox.v1.PolicySource + (*SandboxPolicy)(nil), // 3: openshell.sandbox.v1.SandboxPolicy + (*FilesystemPolicy)(nil), // 4: openshell.sandbox.v1.FilesystemPolicy + (*LandlockPolicy)(nil), // 5: openshell.sandbox.v1.LandlockPolicy + (*ProcessPolicy)(nil), // 6: openshell.sandbox.v1.ProcessPolicy + (*UiPolicy)(nil), // 7: openshell.sandbox.v1.UiPolicy + (*NetworkPolicyRule)(nil), // 8: openshell.sandbox.v1.NetworkPolicyRule + (*NetworkMiddlewareConfig)(nil), // 9: openshell.sandbox.v1.NetworkMiddlewareConfig + (*MiddlewareEndpointSelector)(nil), // 10: openshell.sandbox.v1.MiddlewareEndpointSelector + (*NetworkCredentialBinding)(nil), // 11: openshell.sandbox.v1.NetworkCredentialBinding + (*NetworkEndpoint)(nil), // 12: openshell.sandbox.v1.NetworkEndpoint + (*McpOptions)(nil), // 13: openshell.sandbox.v1.McpOptions + (*GraphqlOperation)(nil), // 14: openshell.sandbox.v1.GraphqlOperation + (*L7DenyRule)(nil), // 15: openshell.sandbox.v1.L7DenyRule + (*L7Rule)(nil), // 16: openshell.sandbox.v1.L7Rule + (*L7Allow)(nil), // 17: openshell.sandbox.v1.L7Allow + (*L7QueryMatcher)(nil), // 18: openshell.sandbox.v1.L7QueryMatcher + (*NetworkBinary)(nil), // 19: openshell.sandbox.v1.NetworkBinary + (*GetSandboxConfigRequest)(nil), // 20: openshell.sandbox.v1.GetSandboxConfigRequest + (*GetGatewayConfigRequest)(nil), // 21: openshell.sandbox.v1.GetGatewayConfigRequest + (*GetGatewayConfigResponse)(nil), // 22: openshell.sandbox.v1.GetGatewayConfigResponse + (*SettingValue)(nil), // 23: openshell.sandbox.v1.SettingValue + (*EffectiveSetting)(nil), // 24: openshell.sandbox.v1.EffectiveSetting + (*GetSandboxConfigResponse)(nil), // 25: openshell.sandbox.v1.GetSandboxConfigResponse + (*SupervisorMiddlewareService)(nil), // 26: openshell.sandbox.v1.SupervisorMiddlewareService + nil, // 27: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry + nil, // 28: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry + nil, // 29: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry + nil, // 30: openshell.sandbox.v1.L7DenyRule.QueryEntry + nil, // 31: openshell.sandbox.v1.L7DenyRule.ParamsEntry + nil, // 32: openshell.sandbox.v1.L7Allow.QueryEntry + nil, // 33: openshell.sandbox.v1.L7Allow.ParamsEntry + nil, // 34: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry + nil, // 35: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry + (*structpb.Struct)(nil), // 36: google.protobuf.Struct + (*durationpb.Duration)(nil), // 37: google.protobuf.Duration } var file_sandbox_proto_depIdxs = []int32{ - 3, // 0: openshell.sandbox.v1.SandboxPolicy.filesystem:type_name -> openshell.sandbox.v1.FilesystemPolicy - 4, // 1: openshell.sandbox.v1.SandboxPolicy.landlock:type_name -> openshell.sandbox.v1.LandlockPolicy - 5, // 2: openshell.sandbox.v1.SandboxPolicy.process:type_name -> openshell.sandbox.v1.ProcessPolicy - 25, // 3: openshell.sandbox.v1.SandboxPolicy.network_policies:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry - 26, // 4: openshell.sandbox.v1.SandboxPolicy.network_middlewares:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry - 10, // 5: openshell.sandbox.v1.NetworkPolicyRule.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint - 17, // 6: openshell.sandbox.v1.NetworkPolicyRule.binaries:type_name -> openshell.sandbox.v1.NetworkBinary - 34, // 7: openshell.sandbox.v1.NetworkMiddlewareConfig.config:type_name -> google.protobuf.Struct - 8, // 8: openshell.sandbox.v1.NetworkMiddlewareConfig.endpoints:type_name -> openshell.sandbox.v1.MiddlewareEndpointSelector - 14, // 9: openshell.sandbox.v1.NetworkEndpoint.rules:type_name -> openshell.sandbox.v1.L7Rule - 13, // 10: openshell.sandbox.v1.NetworkEndpoint.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule - 27, // 11: openshell.sandbox.v1.NetworkEndpoint.graphql_persisted_queries:type_name -> openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry - 11, // 12: openshell.sandbox.v1.NetworkEndpoint.mcp:type_name -> openshell.sandbox.v1.McpOptions - 9, // 13: openshell.sandbox.v1.NetworkEndpoint.credential_binding:type_name -> openshell.sandbox.v1.NetworkCredentialBinding - 28, // 14: openshell.sandbox.v1.L7DenyRule.query:type_name -> openshell.sandbox.v1.L7DenyRule.QueryEntry - 29, // 15: openshell.sandbox.v1.L7DenyRule.params:type_name -> openshell.sandbox.v1.L7DenyRule.ParamsEntry - 15, // 16: openshell.sandbox.v1.L7Rule.allow:type_name -> openshell.sandbox.v1.L7Allow - 30, // 17: openshell.sandbox.v1.L7Allow.query:type_name -> openshell.sandbox.v1.L7Allow.QueryEntry - 31, // 18: openshell.sandbox.v1.L7Allow.params:type_name -> openshell.sandbox.v1.L7Allow.ParamsEntry - 32, // 19: openshell.sandbox.v1.GetGatewayConfigResponse.settings:type_name -> openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry - 21, // 20: openshell.sandbox.v1.EffectiveSetting.value:type_name -> openshell.sandbox.v1.SettingValue - 0, // 21: openshell.sandbox.v1.EffectiveSetting.scope:type_name -> openshell.sandbox.v1.SettingScope - 2, // 22: openshell.sandbox.v1.GetSandboxConfigResponse.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 33, // 23: openshell.sandbox.v1.GetSandboxConfigResponse.settings:type_name -> openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry - 1, // 24: openshell.sandbox.v1.GetSandboxConfigResponse.policy_source:type_name -> openshell.sandbox.v1.PolicySource - 24, // 25: openshell.sandbox.v1.GetSandboxConfigResponse.supervisor_middleware_services:type_name -> openshell.sandbox.v1.SupervisorMiddlewareService - 35, // 26: openshell.sandbox.v1.SupervisorMiddlewareService.request_timeout:type_name -> google.protobuf.Duration - 6, // 27: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry.value:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 7, // 28: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry.value:type_name -> openshell.sandbox.v1.NetworkMiddlewareConfig - 12, // 29: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry.value:type_name -> openshell.sandbox.v1.GraphqlOperation - 16, // 30: openshell.sandbox.v1.L7DenyRule.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 16, // 31: openshell.sandbox.v1.L7DenyRule.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 16, // 32: openshell.sandbox.v1.L7Allow.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 16, // 33: openshell.sandbox.v1.L7Allow.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 21, // 34: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.SettingValue - 22, // 35: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.EffectiveSetting - 36, // [36:36] is the sub-list for method output_type - 36, // [36:36] is the sub-list for method input_type - 36, // [36:36] is the sub-list for extension type_name - 36, // [36:36] is the sub-list for extension extendee - 0, // [0:36] is the sub-list for field type_name + 4, // 0: openshell.sandbox.v1.SandboxPolicy.filesystem:type_name -> openshell.sandbox.v1.FilesystemPolicy + 5, // 1: openshell.sandbox.v1.SandboxPolicy.landlock:type_name -> openshell.sandbox.v1.LandlockPolicy + 6, // 2: openshell.sandbox.v1.SandboxPolicy.process:type_name -> openshell.sandbox.v1.ProcessPolicy + 27, // 3: openshell.sandbox.v1.SandboxPolicy.network_policies:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry + 28, // 4: openshell.sandbox.v1.SandboxPolicy.network_middlewares:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry + 7, // 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 + 12, // 7: openshell.sandbox.v1.NetworkPolicyRule.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint + 19, // 8: openshell.sandbox.v1.NetworkPolicyRule.binaries:type_name -> openshell.sandbox.v1.NetworkBinary + 36, // 9: openshell.sandbox.v1.NetworkMiddlewareConfig.config:type_name -> google.protobuf.Struct + 10, // 10: openshell.sandbox.v1.NetworkMiddlewareConfig.endpoints:type_name -> openshell.sandbox.v1.MiddlewareEndpointSelector + 16, // 11: openshell.sandbox.v1.NetworkEndpoint.rules:type_name -> openshell.sandbox.v1.L7Rule + 15, // 12: openshell.sandbox.v1.NetworkEndpoint.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule + 29, // 13: openshell.sandbox.v1.NetworkEndpoint.graphql_persisted_queries:type_name -> openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry + 13, // 14: openshell.sandbox.v1.NetworkEndpoint.mcp:type_name -> openshell.sandbox.v1.McpOptions + 11, // 15: openshell.sandbox.v1.NetworkEndpoint.credential_binding:type_name -> openshell.sandbox.v1.NetworkCredentialBinding + 30, // 16: openshell.sandbox.v1.L7DenyRule.query:type_name -> openshell.sandbox.v1.L7DenyRule.QueryEntry + 31, // 17: openshell.sandbox.v1.L7DenyRule.params:type_name -> openshell.sandbox.v1.L7DenyRule.ParamsEntry + 17, // 18: openshell.sandbox.v1.L7Rule.allow:type_name -> openshell.sandbox.v1.L7Allow + 32, // 19: openshell.sandbox.v1.L7Allow.query:type_name -> openshell.sandbox.v1.L7Allow.QueryEntry + 33, // 20: openshell.sandbox.v1.L7Allow.params:type_name -> openshell.sandbox.v1.L7Allow.ParamsEntry + 34, // 21: openshell.sandbox.v1.GetGatewayConfigResponse.settings:type_name -> openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry + 23, // 22: openshell.sandbox.v1.EffectiveSetting.value:type_name -> openshell.sandbox.v1.SettingValue + 1, // 23: openshell.sandbox.v1.EffectiveSetting.scope:type_name -> openshell.sandbox.v1.SettingScope + 3, // 24: openshell.sandbox.v1.GetSandboxConfigResponse.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 35, // 25: openshell.sandbox.v1.GetSandboxConfigResponse.settings:type_name -> openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry + 2, // 26: openshell.sandbox.v1.GetSandboxConfigResponse.policy_source:type_name -> openshell.sandbox.v1.PolicySource + 26, // 27: openshell.sandbox.v1.GetSandboxConfigResponse.supervisor_middleware_services:type_name -> openshell.sandbox.v1.SupervisorMiddlewareService + 37, // 28: openshell.sandbox.v1.SupervisorMiddlewareService.request_timeout:type_name -> google.protobuf.Duration + 8, // 29: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry.value:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 9, // 30: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry.value:type_name -> openshell.sandbox.v1.NetworkMiddlewareConfig + 14, // 31: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry.value:type_name -> openshell.sandbox.v1.GraphqlOperation + 18, // 32: openshell.sandbox.v1.L7DenyRule.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 18, // 33: openshell.sandbox.v1.L7DenyRule.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 18, // 34: openshell.sandbox.v1.L7Allow.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 18, // 35: openshell.sandbox.v1.L7Allow.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 23, // 36: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.SettingValue + 24, // 37: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.EffectiveSetting + 38, // [38:38] is the sub-list for method output_type + 38, // [38:38] is the sub-list for method input_type + 38, // [38:38] is the sub-list for extension type_name + 38, // [38:38] is the sub-list for extension extendee + 0, // [0:38] is the sub-list for field type_name } func init() { file_sandbox_proto_init() } @@ -2344,8 +2497,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), @@ -2356,8 +2509,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: 2, - NumMessages: 32, + NumEnums: 3, + NumMessages: 33, NumExtensions: 0, NumServices: 0, }, diff --git a/skills/generate-sandbox-policy/SKILL.md b/skills/generate-sandbox-policy/SKILL.md index 73c0863df7..2bc77b446e 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). @@ -257,6 +258,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: @@ -381,6 +404,7 @@ Before presenting the policy to the user, verify correctness **and** flag breadt - [ ] No fail-closed middleware selector can cover a `tls: skip` endpoint - [ ] Any required WebSocket control advertises `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`, and the user understands that V1 does not inspect binary messages - [ ] 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) @@ -443,7 +467,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**: @@ -462,7 +486,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 2cbc21b6b6..a547ea5398 100644 --- a/skills/generate-sandbox-policy/examples.md +++ b/skills/generate-sandbox-policy/examples.md @@ -748,7 +748,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 2e4c9c3bdf..88477b260f 100644 --- a/skills/openshell-cli/SKILL.md +++ b/skills/openshell-cli/SKILL.md @@ -408,6 +408,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 @@ -415,6 +420,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 @@ -439,7 +448,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 creation: `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 creation: `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. MXC rejects live policy replacement and merge updates; delete and recreate an MXC sandbox instead. UI capabilities are enforced only by a configured driver/backend advertising complete UI-policy support. Today that is the MXC driver's OpenShell `process_container` backend, which emits MXC's `processcontainer` containment value. `isolation_session` and non-Windows drivers reject any explicit UI section before provisioning; omit it to preserve their existing behavior. An endpoint with omitted `protocol` retains explicit-proxy behavior. Explicit `protocol: tcp` requests policy DNS and transparent TCP and currently requires @@ -509,7 +518,7 @@ Edit `current-policy.yaml` to allow the blocked actions. **For policy content au - Binary matching patterns - Ordered `network_middlewares`, host selection, HTTP 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 parsed HTTP request bodies and complete client-to-upstream WebSocket text messages over both `ws://` and `wss://` when the implementation advertises the matching binding. The built-in `openshell/regex` advertises both bindings and applies its fixed patterns to UTF-8 text. A host-matched HTTP-only attachment can inspect the upgrade GET but does not join the WebSocket chain; look for `binding_not_selected` coverage. Binary messages pass under both `on_error` modes and active stages emit `unsupported_message_type` coverage; upstream-to-client messages remain uninspected. A broken fail-open WebSocket stage is disabled for the rest of that connection; inspect sandbox OCSF logs for `openshell.middleware.websocket_stage_disabled`. diff --git a/tasks/scripts/windows-msvc.ps1 b/tasks/scripts/windows-msvc.ps1 index 51179fb1e0..16d8a6b67b 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-supervisor --exclude openshell-supervisor-process --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-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-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..7509a934c6 100644 --- a/tasks/windows.toml +++ b/tasks/windows.toml @@ -76,9 +76,14 @@ 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: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 tasks/scripts/windows-msvc.ps1 test-mxc-real aarch64-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)"